Support Ecaz renewal of temporary Duke tenure
This commit is contained in:
1 parent
4e55ab9e86
commit
69bbb09dcf
12 files changed
+314
-21
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user