Support Ecaz renewal of temporary Duke tenure

This commit is contained in:
Quintine committed 2026-10-27 22:37:04 +11:00
1 parent 4e55ab9e86
commit 69bbb09dcf
12 files changed
+314 -21

No files matched your search

+2 -2
View File
@@ -192,11 +192,11 @@ void test('pure source/canonical identity/custody checks reject safely, with one
EcazDukeAcquisitionError,
);
});
void test('same-owner and Advanced Harkonnen cases remain explicitly unfinished without private reason variation', () => {
void test('redundant same-owner and Advanced Harkonnen cases keep public reasons independent of private custody', () => {
const own = fixture();
own.dukeVidal = acquireDuke(own.dukeVidal!, 'ec', 1, 'ecaz');
const reason = ecazDukeAcquisitionBlock(own, 'ec');
assert.match(reason!, /still being implemented/);
assert.match(reason!, /already controls Duke Vidal through its Ambassador/);
own.dukeVidal.leader.capturedBy = 'in';
assert.equal(ecazDukeAcquisitionBlock(own, 'ec'), reason);
const hark = fixture('harkonnen', true);
+4 -4
View File
@@ -63,20 +63,20 @@ for (const advanced of [false, true]) {
void test(`${band} Ecaz-held Duke may transfer to its ally while same-recipient acquisition remains a no-op refusal`, () => {
const g = position(advanced); g.dukeVidal = acquireDuke(g.dukeVidal!, 'ec', 1, 'ecaz');
const oldReason = block(g, 'ec');
assert.equal(oldReason, 'Acquiring Duke Vidal when Ecaz already controls him is still being implemented.');
assert.equal(oldReason, 'Ecaz already controls Duke Vidal through its Ambassador.');
assert.throws(() => quote(g, 'ec'), error => error instanceof EcazDukeAcquisitionError && error.reason === 'alreadyControlled');
const shared = quote(g, 'ec', 'al'); assert.equal(shared.controller, 'al'); assert.equal(shared.source, 'ecaz');
g.dukeVidal = shared; const before = structuredClone(g);
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal through the Ambassador.');
assert.throws(() => quote(g, 'ec', 'al'), error => error instanceof EcazDukeAcquisitionError && error.reason === 'alreadyControlled');
assert.deepEqual(g, before);
// Public same-controller descriptors do not vary with a private capture or
// Ghola marker. They confer no permission to transfer that custody.
g.dukeVidal.leader.capturedBy = 'g';
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal through the Ambassador.');
rejected(g, 'ec', 'ec');
delete g.dukeVidal.leader.capturedBy; g.dukeVidal.leader.gholaBy = 'g';
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal.');
assert.equal(block(g, 'ec', 'al'), 'The chosen beneficiary already controls Duke Vidal through the Ambassador.');
rejected(g, 'ec', 'ec');
});
}
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type Action, type Game } from '../game/engine';
import { acquireDuke, DUKE_VIDAL_ID } from '../game/duke-vidal';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { EcazDukeAcquisitionChoices } from '../components/ecaz-duke-acquisition-choices';
import { actEcazDukeAlly as act, createEcazDukeAllyFixture as fixture,
ecazDukeAllyInventory as inventory, ecazDukeAllyPhysical as physical,
finishEcazDukeAlly as finish } from './fixture-ecaz-duke-ally';
const aliases=registerHooks({
resolve(specifier,context,next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier,context);
},
load(url,context,next) {
if (!url.endsWith('.module.css')) return next(url,context);
const css=readFileSync(new URL(url),'utf8');
const classes=Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1],match[1]]));
return {format:'module',source:`export default ${JSON.stringify(classes)}`,shortCircuit:true};
},
});
const { EcazEntry } = await import('../components/ecaz-entry');
const { GameTable } = await import('../components/game-table');
aliases.deregister();
type ButtonProps={ 'data-testid'?:string; disabled?:boolean; onClick?:() => void; children?:ReactNode };
function button(node: ReactNode, id:string): ReactElement<ButtonProps> | null {
if (Array.isArray(node)) {
for (const child of node) { const found=button(child,id); if (found) return found; }
return null;
}
if (!isValidElement<ButtonProps>(node)) return null;
if (node.props['data-testid'] === id) return node;
return button(node.props.children,id);
}
/** The fixture earns the alliance, Ecaz Ambassador placement, normal shared
* Duke, paid entry and pending offer through real actions. This single,
* explicitly controlled supplement changes the original shared disc's
* existing control to the already-allied recipient under an ally-loan tenure.
* It does not install a leader, card, force, payment or pending event. */
function loan(advanced:boolean) {
const f=fixture({advanced}), game=structuredClone(f.game);
game.dukeVidal=acquireDuke(game.dukeVidal!,f.ally,game.turn,'ally');
assert.equal(game.dukeVidal.controller,f.ally); assert.equal(game.dukeVidal.source,'ally');
assert.deepEqual(inventory(game),f.originalInventory);
return {f,game};
}
function checkCompleted(f: ReturnType<typeof fixture>, before:Game, game:Game, recipient=f.ally) {
assert.deepEqual([game.dukeVidal!.controller,game.dukeVidal!.source,game.dukeVidal!.acquiredTurn],
[recipient,'ecaz',before.turn]);
assert.deepEqual(game.dukeVidal!.leader,before.dukeVidal!.leader);
assert.deepEqual(physical(game),physical(before));
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone,'supply');
assert.ok(game.players.every(seat => !seat.leaders.some(leader => leader.id === DUKE_VIDAL_ID)));
assert.deepEqual(inventory(game),f.originalInventory);
}
for (const advanced of [false,true]) {
const mode=advanced ? 'Advanced' : 'Basic';
void test(`${mode} actual Ecaz table renders and submits same-recipient Duke renewal`, () => {
const {f,game:staged}=loan(advanced), before=structuredClone(staged);
const view=viewGame(staged,f.owner), entry=view.ambassadorEntry; assert.ok(entry);
assert.equal(entry.effect,'ecaz'); assert.equal(entry.stage,'offer');
assert.equal(entry.beneficiaries.find(row => row.player === f.ally)?.blocked,null);
const entryHtml=renderToStaticMarkup(createElement(EcazEntry,{game:view,busy:false,act() {}}));
const tableHtml=renderToStaticMarkup(createElement(GameTable,{game:view,busy:false,send:async () => {},onExit() {}}));
for (const html of [entryHtml,tableHtml]) {
assert.match(html,/Acquire Duke Vidal for/);
assert.match(html,/renew that control/);
assert.ok(html.includes(`data-testid="ecaz-duke-acquisition-${f.ally}"`));
}
const sent:Action[]=[];
const control=EcazDukeAcquisitionChoices({game:view,busy:false,act(action) {sent.push(action);}});
const choice=button(control,`ecaz-duke-acquisition-${f.ally}`); assert.ok(choice);
assert.equal(choice.props.disabled,false); assert.ok(choice.props.onClick);
choice.props.onClick();
assert.deepEqual(sent,[{type:'decision',event:f.event,trigger:true,beneficiary:f.ally,choice:'duke'}]);
let game=act(staged,f.owner,sent[0]); game=finish(f,game);
assert.deepEqual(staged,before,'The actual callback does not mutate its input');
checkCompleted(f,before,game);
// Server-rendered controls and this leaf callback are exercised; a browser
// hydration/navigation or external deployed session is not represented.
});
void test(`${mode} busy renewal controls do not submit and all four bots choose a legal native acquisition`, () => {
const {f,game:staged}=loan(advanced), original=structuredClone(staged);
const view=viewGame(staged,f.owner), sent:Action[]=[];
const control=EcazDukeAcquisitionChoices({game:view,busy:true,act(action) {sent.push(action);}});
const choice=button(control,`ecaz-duke-acquisition-${f.ally}`); assert.ok(choice);
assert.equal(choice.props.disabled,true); choice.props.onClick?.(); assert.deepEqual(sent,[]);
for (const difficulty of DIFFICULTIES) {
const bot=viewGame(staged,f.owner);
bot.players.find(row => row.id === f.owner)!.bot=difficulty;
const action=botActions(bot)[0]; assert.ok(action,difficulty);
assert.equal(action.type,'decision'); assert.equal(action.event,f.event);
assert.equal(action.trigger,true); assert.equal(action.choice,'duke');
assert.equal(typeof action.beneficiary,'string');
const recipient=String(action.beneficiary);
assert.ok(bot.ambassadorEntry?.beneficiaries.some(row => row.player === recipient && !row.blocked),
'The bot selects a server-projected legal recipient without imposing a strategic preference');
let game=act(structuredClone(staged),f.owner,action); game=finish(f,game);
checkCompleted(f,original,game,recipient);
}
assert.deepEqual(staged,original);
});
}
+125
View File
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { acquireDuke, createDukeVidal, expireDuke, DUKE_VIDAL_ID, type DukeSource } from '../game/duke-vidal';
import { ecazDukeAcquisitionBlock, quoteEcazDukeAcquisition } from '../game/ecaz-duke-acquisition';
import { actEcazDukeAlly, advanceEcazDukeAlly, battleEcazDukeAlly, createEcazDukeAllyFixture,
ecazDukeAllyClean, ecazDukeAllyInventory, ecazDukeAllyPhysical, finishEcazDukeAlly } from './fixture-ecaz-duke-ally';
/** A controlled public quote position, not a claimed natural acquisition. */
function position(source: DukeSource, recipient: 'ec' | 'mor' = 'ec'): Game {
const game = createGame('DUKEREACQUIRE', newPlayer('ec', 'Ecaz', 'ecaz'), false, ['ecaz']);
game.players.push(newPlayer('mor', 'Moritani', 'moritani'), newPlayer('other', 'Emperor', 'emperor'));
Object.assign(game, { status: 'playing', turn: 2, phase: 5, active: 'other' });
game.players[0].ally = 'mor'; game.players[1].ally = 'ec';
game.dukeVidal = acquireDuke(createDukeVidal(), recipient, 2, source);
return game;
}
for (const [source, recipient] of [
['ally', 'mor'], ['moritani', 'mor'], ['ecazNexus', 'ec'],
] as const) {
void test(`same-recipient ${source} tenure can become ordinary Ecaz Ambassador tenure`, () => {
const game = position(source, recipient);
game.dukeVidal!.leader.deaths = 2;
game.dukeVidal!.leader.usedAt = 'arrakeen';
const before = structuredClone(game), original = structuredClone(game.dukeVidal!);
assert.equal(ecazDukeAcquisitionBlock(game, 'ec', recipient), null);
const quote = quoteEcazDukeAcquisition(game, 'ec', recipient);
assert.deepEqual(game, before, 'A detached quotation never changes the live disc');
assert.deepEqual(quote, { ...original, source: 'ecaz', acquiredTurn: game.turn });
assert.deepEqual(quote.leader, original.leader, 'Physical identity, prior use and deaths stay unchanged');
assert.deepEqual(expireDuke(quote, game.turn), quote, 'Unused ordinary acquisition is retained after the turn');
assert.notEqual(expireDuke(original, game.turn).controller, recipient, 'The original temporary tenure would expire');
quote.leader.deaths = 99;
assert.deepEqual(game, before, 'The quoted leader is detached from the source');
});
}
void test('redundant ordinary tenure and invalid same-recipient custody remain unavailable', () => {
const game = position('ecaz');
const original = structuredClone(game);
assert.ok(ecazDukeAcquisitionBlock(game, 'ec', 'ec'));
assert.throws(() => quoteEcazDukeAcquisition(game, 'ec', 'ec'));
assert.deepEqual(game, original);
for (const mutate of [
(g: Game) => { g.dukeVidal!.leader.dead = true; },
(g: Game) => { g.dukeVidal!.leader.capturedBy = 'other'; },
(g: Game) => { g.dukeVidal!.leader.gholaBy = 'other'; },
(g: Game) => { g.dukeVidal!.leader.id = 'wrong-disc'; },
]) {
const bad = position('ally', 'mor'); mutate(bad);
const before = structuredClone(bad);
assert.ok(ecazDukeAcquisitionBlock(bad, 'ec', 'mor'));
assert.throws(() => quoteEcazDukeAcquisition(bad, 'ec', 'mor'));
assert.deepEqual(bad, before);
}
});
void test('same-controller exceptional custody cannot change the public redundant-control reason', () => {
const game = position('ecaz'), reason = ecazDukeAcquisitionBlock(game, 'ec', 'ec');
assert.ok(reason);
for (const mutate of [
(g: Game) => { g.dukeVidal!.leader.capturedBy = 'other'; },
(g: Game) => { g.dukeVidal!.leader.gholaBy = 'other'; },
]) {
const hidden = structuredClone(game); mutate(hidden);
assert.equal(ecazDukeAcquisitionBlock(hidden, 'ec', 'ec'), reason);
assert.throws(() => quoteEcazDukeAcquisition(hidden, 'ec', 'ec'));
}
});
void test('Advanced Harkonnen does not disclose a concealed same-controller capture via the reacquisition offer', () => {
const game = position('ecazNexus');
game.advanced = true;
game.players[1].faction = 'harkonnen';
// Existing independent-Discovery profile admits Advanced Harkonnen. These
// two controlled projections differ only in hidden Duke capture custody.
game.players[0].ally = null; game.players[1].ally = null;
game.ecazTreachery = true; game.discoveryEnabled = true;
const hidden = structuredClone(game);
hidden.dukeVidal!.leader.capturedBy = 'mor';
hidden.dukeVidal!.leader.concealed = { captor: 'mor', controller: 'ec', dead: false, deaths: 0 };
hidden.dukeVidal!.captureVisibility = { controller: 'ec', acquiredTurn: 2, source: 'ecazNexus' };
const publicReason = ecazDukeAcquisitionBlock(game, 'ec', 'ec');
assert.ok(publicReason, 'The capture-sensitive offer remains uniformly unavailable');
assert.equal(ecazDukeAcquisitionBlock(hidden, 'ec', 'ec'), publicReason);
assert.throws(() => quoteEcazDukeAcquisition(game, 'ec', 'ec'));
assert.throws(() => quoteEcazDukeAcquisition(hidden, 'ec', 'ec'));
});
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} real Ambassador entry can regrant a controlled ally loan without replaying arrival`, () => {
const f = createEcazDukeAllyFixture({ advanced });
const staged = structuredClone(f.game);
// Controlled custody supplement: the fixture earned the unique shared disc,
// alliance, token placement and paid entry through real actions. Reassign
// that same disc to the existing ally with the temporary loan source; no
// additional leader, card, force or resource is created.
staged.dukeVidal = acquireDuke(staged.dukeVidal!, f.ally, staged.turn, 'ally');
const original = structuredClone(staged), physical = ecazDukeAllyPhysical(staged);
const offer = viewGame(staged, f.owner).ambassadorEntry;
assert.ok(offer);
assert.equal(offer.beneficiaries!.find(row => row.player === f.ally)!.blocked, null);
const command: Action = { type: 'decision', event: f.event, trigger: true, beneficiary: f.ally, choice: 'duke' };
let game = actEcazDukeAlly(staged, f.owner, command);
game = finishEcazDukeAlly(f, game);
assert.deepEqual(staged, original, 'The accepted action does not mutate its input');
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
[f.ally, 'ecaz', original.turn]);
assert.deepEqual(game.dukeVidal!.leader, original.dukeVidal!.leader);
assert.deepEqual(ecazDukeAllyPhysical(game), physical);
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'supply');
assert.deepEqual(ecazDukeAllyInventory(game), f.originalInventory);
assert.ok(game.players.every(seat => !seat.leaders.some(leader => leader.id === DUKE_VIDAL_ID)));
assert.throws(() => actEcazDukeAlly(game, f.owner, command), 'The same token event cannot be replayed');
const fought = battleEcazDukeAlly(f, game, [], { useDuke: false });
const next = advanceEcazDukeAlly(fought.game,
state => state.turn === 3 && state.phase === 1 && ecazDukeAllyClean(state));
assert.deepEqual([next.dukeVidal!.controller, next.dukeVidal!.source, next.dukeVidal!.acquiredTurn],
[f.ally, 'ecaz', original.turn], 'Unused renewed tenure survives the actual Mentat boundary');
assert.deepEqual(next.dukeVidal!.leader, original.dukeVidal!.leader);
assert.deepEqual(ecazDukeAllyInventory(next), f.originalInventory);
});
}