Allow Ecaz Ambassador Duke acquisition for an existing ally

This commit is contained in:
Quintine committed 2026-10-26 08:31:55 +11:00
1 parent cab6b41c67
commit 34dccdfd99
21 files changed
+965 -86

No files matched your search

+8 -1
View File
@@ -286,10 +286,17 @@ void test('explicit Duke choice/owner/event and eligibility are checked before t
reject(g, 'ec', { ...action, event: 'stale' });
reject(g, 'ec', { ...action, choice: undefined });
reject(g, 'ec', { ...action, choice: 'loan' });
reject(g, 'ec', { ...action, beneficiary: 'al' });
const allied = reload(g);
p(allied, 'ec').ally = 'al';
p(allied, 'al').ally = 'ec';
reject(allied, 'ec', { ...action, beneficiary: 'al' });
const shared = applyAction(allied, 'ec', { ...action, beneficiary: 'al' });
assert.equal(shared.dukeVidal!.controller, 'al');
assert.equal(shared.dukeVidal!.source, 'ecaz');
assert.deepEqual(shared.players, allied.players);
const staleAlly = reload(allied);
p(staleAlly, 'al').ally = null;
reject(staleAlly, 'ec', { ...action, beneficiary: 'al' });
for (const mutate of [
(b: Game) => {
delete b.dukeVidal;
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { EcazDukeAcquisitionChoices } from '../components/ecaz-duke-acquisition-choices';
import { createDukeVidal } from '../game/duke-vidal';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
},
});
const { EcazEntry } = await import('../components/ecaz-entry');
aliases.deregister();
// Controlled projections supplement the separate native acquisition sources.
// Only the public view is changed: no played alliance, Duke custody, Ambassador
// token, force, hand or entry is installed in a Game. React renders the stateful
// wrapper; only the hook-free choices leaf is called directly for callbacks.
function projected(advanced = false): GameView {
const game = createGame('ECAZDUKEALLYCONTROLS', newPlayer('e', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
joinGame(game, newPlayer('a', 'Atreides ally', 'atreides'));
joinGame(game, newPlayer('i', 'Emperor entrant', 'emperor'));
const view = viewGame(game, 'e');
view.status = 'playing'; view.phase = 5; view.turn = 2;
view.decision = { kind: 'ecazAmbassador', player: 'e' };
view.dukeVidal = createDukeVidal();
view.ambassadorEntry = {
event: 'controlled-ecaz-duke-entry', owner: 'e', entrant: 'i',
territory: 'arrakeen', stage: 'offer', effect: 'ecaz', beneficiary: null,
noField: null, shipment: null, movement: null,
allianceOffer: { blocked: 'The entrant is already allied.' },
dukeAcquisition: { blocked: null }, dukeLoan: null,
beneficiaries: [{ player: 'e', blocked: null }, { player: 'a', blocked: null }],
copies: [], cards: [], revival: null,
};
return view;
}
function markup(game: GameView, busy = false, wrapper = false) {
return renderToStaticMarkup(createElement(wrapper ? EcazEntry : EcazDukeAcquisitionChoices, {
game, busy, act() {},
}));
}
function button(html: string, recipient: string) {
const found = html.match(new RegExp(`<button\\b(?=[^>]*data-testid="ecaz-duke-acquisition-${recipient}")[^>]*>[\\s\\S]*?<\\/button>`))?.[0];
assert.ok(found, `Duke choice for ${recipient}`); return found;
}
function click(node: ReactNode, recipient: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{
children?: ReactNode; onClick?: () => void; 'data-testid'?: string;
}>(child)) continue;
if (child.props['data-testid'] === `ecaz-duke-acquisition-${recipient}` && child.props.onClick) {
child.props.onClick(); return true;
}
if (click(child.props.children, recipient)) return true;
}
return false;
}
function choose(game: GameView, recipient: string, busy = false): Action[] {
const actions: Action[] = [];
click(EcazDukeAcquisitionChoices({ game, busy, act(action) { actions.push(action); } }), recipient);
return actions;
}
void test('Ecaz Duke choices allow the projected existing ally when self acquisition is blocked', () => {
for (const advanced of [false, true]) {
const game = projected(advanced), entry = game.ambassadorEntry!;
entry.dukeAcquisition!.blocked = 'Ecaz already controls Duke Vidal.';
entry.beneficiaries[0].blocked = 'Ecaz already controls Duke Vidal.';
const html = markup(game);
assert.match(button(html, 'e'), /\sdisabled=/);
assert.doesNotMatch(button(html, 'a'), /\sdisabled=/);
assert.match(html, /Ecaz already controls Duke Vidal/);
assert.match(html, /Acquire Duke Vidal for Atreides ally/);
assert.deepEqual(choose(game, 'e'), []);
assert.deepEqual(choose(game, 'a'), [{
type: 'decision', event: entry.event, trigger: true, beneficiary: 'a', choice: 'duke',
}]);
}
});
void test('each offered Duke recipient sends exactly its own event-bound acquisition action', () => {
const game = projected(), before = structuredClone(game);
for (const recipient of ['e', 'a']) {
assert.deepEqual(choose(game, recipient), [{
type: 'decision', event: 'controlled-ecaz-duke-entry', trigger: true,
beneficiary: recipient, choice: 'duke',
}]);
assert.doesNotMatch(button(markup(game), recipient), /\sdisabled=/);
}
assert.deepEqual(choose(game, 'i'), [], 'No unoffered entrant choice is manufactured');
assert.deepEqual(game, before);
});
void test('busy and per-recipient blocks suppress actual Duke callbacks while reasons remain visible', () => {
const game = projected();
assert.deepEqual(choose(game, 'e', true), []); assert.deepEqual(choose(game, 'a', true), []);
const busy = markup(game, true);
assert.match(button(busy, 'e'), /\sdisabled=/); assert.match(button(busy, 'a'), /\sdisabled=/);
game.ambassadorEntry!.beneficiaries[1].blocked = 'The chosen beneficiary already controls Duke Vidal.';
for (let render = 0; render < 3; render++) {
const html = markup(game);
assert.match(html, /The chosen beneficiary already controls Duke Vidal/);
assert.match(button(html, 'a'), /\sdisabled=/);
assert.match(button(html, 'a'), /aria-describedby="ecaz-duke-controlled-ecaz-duke-entry-a-blocked"/);
assert.doesNotMatch(button(html, 'e'), /\sdisabled=/);
assert.deepEqual(choose(game, 'a'), []);
}
});
void test('an empty beneficiary list retains only the old self descriptor and its unavailable reason', () => {
const game = projected(), entry = game.ambassadorEntry!;
entry.beneficiaries = [];
assert.match(markup(game), /Acquire Duke Vidal for Ecaz/);
assert.doesNotMatch(markup(game), /Acquire Duke Vidal for Atreides ally/);
assert.deepEqual(choose(game, 'e'), [{
type: 'decision', event: entry.event, trigger: true, beneficiary: 'e', choice: 'duke',
}]);
assert.deepEqual(choose(game, 'a'), []);
entry.dukeAcquisition!.blocked = 'Duke Vidal is unavailable for this acquisition.';
assert.match(markup(game), /Duke Vidal is unavailable for this acquisition/);
assert.deepEqual(choose(game, 'e'), []);
entry.dukeAcquisition = null;
assert.match(markup(game), /Duke acquisition is unavailable for this opportunity/);
assert.deepEqual(choose(game, 'e'), []);
});
void test('waiting foreign and closed entry views never expose a standalone Duke transfer', () => {
for (const change of ['absent', 'viewer', 'owner', 'actor', 'decision', 'stage', 'effect'] as const) {
const game = projected();
if (change === 'absent') game.ambassadorEntry = null;
if (change === 'viewer') game.me = 'a';
if (change === 'owner') game.ambassadorEntry!.owner = 'i';
if (change === 'actor') game.decision!.player = 'i';
if (change === 'decision') game.decision = null;
if (change === 'stage') game.ambassadorEntry!.stage = 'loan';
if (change === 'effect') game.ambassadorEntry!.effect = 'fremen';
assert.equal(markup(game), '', change);
assert.deepEqual(choose(game, 'e'), [], change); assert.deepEqual(choose(game, 'a'), [], change);
}
const waiting = projected(); waiting.decision!.player = 'i';
assert.equal(markup(waiting, false, true), '');
});
void test('ordinary Duke tenure guidance stays readable and distinguishes the new-alliance turn loan', () => {
const game = projected();
for (let render = 0; render < 3; render++) {
const html = markup(game);
assert.match(html, /Provisional ordinary acquisition/);
assert.match(html, /until battle use, including when unused beyond this turn/);
assert.match(html, /The separate Duke loan after a new alliance lasts for the current turn/);
assert.match(html, /Only Ecaz may revive him/);
assert.match(html, /The Ambassador returns to your supply/);
assert.doesNotMatch(html, /Confirm|Acknowledge|countdown|once per turn/);
}
});
void test('the React-rendered entry retains one Duke inspector and its original alliance and loan controls', () => {
const game = projected();
game.ambassadorEntry!.beneficiaries[0].blocked = 'The self choice is blocked.';
const html = markup(game, false, true);
assert.equal((html.match(/aria-label="Inspect leader: Duke Prad Vidal"/g) ?? []).length, 1);
assert.match(html, /Acquire Duke Vidal for Atreides ally/);
assert.match(html, /Offer alliance to Emperor entrant/);
assert.match(html, /The entrant is already allied/);
assert.match(html, /Leave Ambassador in place/);
assert.doesNotMatch(button(html, 'a'), /\sdisabled=/);
game.ambassadorEntry!.stage = 'loan';
const loan = markup(game, false, true);
assert.match(loan, /Lend Duke Vidal to the new ally/);
assert.match(loan, /Continue without lending Duke/);
assert.match(loan, /unused living Duke is set aside at turn end/);
assert.doesNotMatch(loan, /ecaz-duke-acquisition-|Provisional ordinary acquisition/);
});
void test('Duke recipient controls use only public names and authoritative beneficiary reasons', () => {
const game = projected();
Object.defineProperty(game.ambassadorEntry!, 'dukeAcquisition', { get() { throw Error('Read the compatibility self descriptor despite offered beneficiaries'); } });
Object.defineProperty(game, 'dukeVidal', { get() { throw Error('Read Duke custody to recreate projected eligibility'); } });
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'leaders', 'forces'])
Object.defineProperty(player, field, { get() { throw Error(`Duke choices read private or custody field ${field}`); } });
assert.match(markup(game), /Acquire Duke Vidal for Atreides ally/);
assert.deepEqual(choose(game, 'a'), [{
type: 'decision', event: 'controlled-ecaz-duke-entry', trigger: true, beneficiary: 'a', choice: 'duke',
}]);
});
+149
View File
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actEcazDukeAlly, advanceEcazDukeAlly, battleEcazDukeAlly, createEcazDukeAllyFixture,
ecazDukeAllyClean, ecazDukeAllyInventory, ecazDukeAllyPhysical,
ecazDukeAllyPlayer as player, finishEcazDukeAlly, grantEcazDukeAlly,
} from './fixture-ecaz-duke-ally';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => actEcazDukeAlly(game, actor, action));
assert.deepEqual(game, before);
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native already-allied Ecaz Ambassador transfers its held ordinary Duke to the ally`, () => {
const f = createEcazDukeAllyFixture({ advanced });
assert.equal(f.game.status, 'playing');
assert.equal(f.game.turn, 2);
assert.equal(f.game.phase, 5);
assert.equal(f.game.active, f.entrant);
assert.equal(f.game.pendingAmbassador!.stage, 'offer');
assert.equal(f.game.pendingAmbassador!.owner, f.owner);
assert.equal(f.game.pendingAmbassador!.entrant, f.entrant);
assert.equal(f.game.pendingAmbassador!.token, f.token);
assert.equal(f.game.pendingAmbassador!.territory, 'carthag');
assert.equal(f.game.response ?? null, null);
assert.equal(f.game.phaseOpening ?? null, null);
assert.equal(f.game.battle, null);
assert.equal(player(f.game, f.owner).ally, f.ally);
assert.equal(player(f.game, f.ally).ally, f.owner);
assert.equal(player(f.game, f.entrant).ally, null);
assert.deepEqual([f.afterSelf.dukeVidal!.controller, f.afterSelf.dukeVidal!.source, f.afterSelf.dukeVidal!.acquiredTurn],
[f.owner, 'ecaz', 1]);
assert.equal(f.afterMentat.dukeVidal!.controller, f.owner);
assert.deepEqual([f.game.dukeVidal!.controller, f.game.dukeVidal!.source, f.game.dukeVidal!.acquiredTurn],
[f.owner, 'ecaz', 1]);
assert.equal(f.placements.length, 2);
assert.ok(f.placements.every(placement => placement.action.token === f.token));
assert.equal(f.shipments.length, 3);
assert.equal(f.shipments[0].step.action.territory, 'habbanya_ridge_sietch');
assert.equal(f.shipments[1].step.actor, f.ally);
assert.equal(f.shipments[2].step.actor, f.entrant);
assert.ok(f.alliance.actions.some(step => step.actor === f.owner && step.action.type === 'alliance'));
assert.ok(f.alliance.actions.some(step => step.actor === f.ally && step.action.type === 'alliance'));
const offer = viewGame(f.game, f.owner).ambassadorEntry;
assert.ok(offer);
assert.ok(offer.beneficiaries!.find(target => target.player === f.owner)!.blocked);
assert.equal(offer.beneficiaries!.find(target => target.player === f.ally)!.blocked, null);
assert.equal(player(f.game, f.ally).forces['carthag:11'], 1);
assert.equal(player(f.game, f.entrant).forces['carthag:11'], 1);
assert.equal(f.sourceActionCount, f.actions.length);
assert.deepEqual(ecazDukeAllyInventory(f.game), f.originalInventory);
let game = grantEcazDukeAlly(f);
game = finishEcazDukeAlly(f, game);
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
[f.ally, 'ecaz', 2]);
assert.deepEqual(game.dukeVidal!.leader, f.originalDuke.leader);
assert.ok(game.log.filter(entry => entry.text.includes('acquired Duke Prad Vidal')).at(-1)!.text
.startsWith(`${player(game, f.ally).name} acquired Duke Prad Vidal`));
assert.deepEqual(ecazDukeAllyPhysical(game), f.originalPhysical,
'Only the existing shared disc controller changes after the already-paid native entry');
assert.equal(game.pendingAmbassador ?? null, null);
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'supply');
assert.deepEqual(game.ecazAmbassadors!.cohort, f.cohort);
assert.deepEqual(ecazDukeAllyInventory(game), f.originalInventory);
});
void test(`${advanced ? 'Advanced' : 'Basic'} native existing-ally ordinary Duke fights once and releases the same living shared disc`, () => {
const f = createEcazDukeAllyFixture({ advanced });
const granted = finishEcazDukeAlly(f, grantEcazDukeAlly(f));
const beforeRosters = granted.players.map(seat => ({ id: seat.id, leaders: seat.leaders.map(leader => leader.id) }));
const fought = battleEcazDukeAlly(f, granted);
assert.equal(fought.beforePlans.dukeVidal!.controller, f.ally);
assert.equal(fought.beforePlans.dukeVidal!.source, 'ecaz');
assert.equal(fought.plans[0].actor, f.ally);
assert.equal(fought.plans[0].action.leader, f.duke);
assert.equal(fought.sealed.battle!.plans[f.ally].leader, f.duke);
assert.equal(fought.game.battle, null);
assert.equal(fought.game.lastBattleContext!.winner, f.ally);
assert.equal(fought.game.lastBattleContext!.territory, 'carthag');
assert.deepEqual([fought.game.dukeVidal!.controller, fought.game.dukeVidal!.source, fought.game.dukeVidal!.acquiredTurn],
[null, null, null]);
assert.equal(fought.game.dukeVidal!.leader.id, f.duke);
assert.equal(fought.game.dukeVidal!.leader.dead, false);
assert.equal(fought.game.dukeVidal!.leader.deaths, f.originalDuke.leader.deaths);
assert.equal(player(fought.game, f.ally).forces['carthag:11'], 1);
assert.equal(player(fought.game, f.opponent).forces['carthag:11'] ?? 0, 0);
assert.deepEqual(fought.game.players.map(seat => ({ id: seat.id, leaders: seat.leaders.map(leader => leader.id) })), beforeRosters);
assert.deepEqual(ecazDukeAllyInventory(fought.game), f.originalInventory);
});
void test(`${advanced ? 'Advanced' : 'Basic'} unused existing-ally direct acquisition persists through a real native battle and next Mentat`, () => {
const f = createEcazDukeAllyFixture({ advanced });
const granted = finishEcazDukeAlly(f, grantEcazDukeAlly(f));
const fought = battleEcazDukeAlly(f, granted, [], { useDuke: false });
assert.notEqual(fought.plans[0].action.leader, f.duke);
assert.deepEqual(fought.game.dukeVidal, granted.dukeVidal);
const nextTurn = advanceEcazDukeAlly(fought.game, state => state.turn === 3 && state.phase === 1 && ecazDukeAllyClean(state));
assert.equal(nextTurn.turn, 3);
assert.deepEqual([nextTurn.dukeVidal!.controller, nextTurn.dukeVidal!.source, nextTurn.dukeVidal!.acquiredTurn],
[f.ally, 'ecaz', 2]);
assert.deepEqual(nextTurn.dukeVidal!.leader, granted.dukeVidal!.leader);
assert.equal(player(nextTurn, f.owner).ally, f.ally);
assert.equal(player(nextTurn, f.ally).ally, f.owner);
assert.deepEqual(ecazDukeAllyInventory(nextTurn), f.originalInventory);
});
void test(`${advanced ? 'Advanced' : 'Basic'} live Ecaz Duke entry rejects foreign, stale, self-held and nonally beneficiaries before consuming its token`, () => {
const f = createEcazDukeAllyFixture({ advanced });
rejected(f.game, f.ally, f.action);
rejected(f.game, f.entrant, f.action);
rejected(f.game, f.owner, { ...f.action, event: 'stale' });
rejected(f.game, f.owner, { ...f.action, beneficiary: f.owner });
rejected(f.game, f.owner, { ...f.action, beneficiary: f.entrant });
rejected(f.game, f.owner, { ...f.action, beneficiary: 'guild' });
assert.equal(player(f.beforeSelf, f.owner).ally, null);
assert.equal(player(f.beforeSelf, f.ally).ally, null);
rejected(f.beforeSelf, f.owner, { ...f.action, event: f.beforeSelf.pendingAmbassador!.event });
assert.deepEqual(f.game.dukeVidal, f.originalDuke);
assert.deepEqual(f.game.pendingAmbassador, f.originalParent);
assert.deepEqual(ecazDukeAllyPhysical(f.game), f.originalPhysical);
assert.equal(f.game.ecazAmbassadors!.tokens.find(token => token.id === f.token)!.zone, 'placed');
const granted = finishEcazDukeAlly(f, grantEcazDukeAlly(f));
rejected(granted, f.owner, f.action);
assert.deepEqual(ecazDukeAllyInventory(granted), f.originalInventory);
});
}
void test('four minimal owner profiles choose the actual existing ally when Ecaz already holds the shared Duke', () => {
for (const advanced of [false, true]) {
const f = createEcazDukeAllyFixture({ advanced });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, f.owner);
view.players.find(seat => seat.id === f.owner)!.bot = difficulty;
const action = botActions(view)[0];
assert.deepEqual(action, f.action, `${advanced}/${difficulty}`);
const game = finishEcazDukeAlly(f, actEcazDukeAlly(structuredClone(f.game), f.owner, action));
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn],
[f.ally, 'ecaz', 2]);
assert.deepEqual(game.dukeVidal!.leader, f.originalDuke.leader);
assert.deepEqual(ecazDukeAllyPhysical(game), f.originalPhysical);
assert.deepEqual(ecazDukeAllyInventory(game), f.originalInventory);
}
}
});
+131
View File
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { newPlayer, type Game } from '../game/engine';
import { acquireDuke, consumeDuke, createDukeVidal, expireDuke } from '../game/duke-vidal';
import {
quoteEcazDukeAcquisition as quote, ecazDukeAcquisitionBlock as block,
EcazDukeAcquisitionError, type EcazDukeContext,
} from '../game/ecaz-duke-acquisition';
type Position = Omit<EcazDukeContext, 'players'> & { players: Game['players'] };
/** Controlled pure-rule inputs only: no native setup, acquisition, alliance or
* battle chronology is claimed. The canonical shared disc is never copied to
* one of the four native leader rosters. */
function position(advanced = false): Position {
const players = [newPlayer('ec', 'Ecaz', 'ecaz'), newPlayer('al', 'Ally', 'atreides'),
newPlayer('g', 'Guild', 'guild'), newPlayer('m', 'Moritani', 'moritani')];
players[0].ally = 'al'; players[1].ally = 'ec';
return { status: 'playing', turn: 2, advanced, expansions: ['ecaz'], players, dukeVidal: createDukeVidal() };
}
const seat = (g: Position, id: string) => { const p = g.players.find(p => p.id === id); assert.ok(p); return p; };
function rejected(g: Position, owner = 'ec', recipient = 'al') {
const before = structuredClone(g); assert.throws(() => quote(g, owner, recipient));
assert.equal(block(g, owner, recipient), 'Duke Vidal is unavailable for this acquisition.');
assert.deepEqual(g, before);
}
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
void test(`${band} ordinary Ecaz Duke quote preserves the existing default self acquisition`, () => {
const g = position(advanced), before = structuredClone(g);
const own = quote(g, 'ec');
assert.deepEqual(own, quote(g, 'ec', 'ec'));
assert.deepEqual(own, { leader: g.dukeVidal!.leader, controller: 'ec', acquiredTurn: 2, source: 'ecaz' });
assert.equal(block(g, 'ec'), null); assert.equal(block(g, 'ec', 'ec'), null); assert.deepEqual(g, before);
const legacy: EcazDukeContext = { ...g, players: g.players.map(({ id, faction }) => ({ id, faction })) };
assert.deepEqual(quote(legacy, 'ec'), own, 'Old self-only context callers need no ally property');
assert.equal(block(legacy, 'ec', 'al'), 'Duke Vidal is unavailable for this acquisition.');
});
void test(`${band} a reciprocal ally receives the same living or set-aside Duke with ordinary until-battle tenure`, () => {
for (const prior of ['setAside', 'ecaz', 'ecazNexus', 'moritani', 'ally'] as const) {
const g = position(advanced);
if (prior !== 'setAside')
g.dukeVidal = acquireDuke(g.dukeVidal!, prior === 'ecaz' || prior === 'ecazNexus' ? 'ec' : 'm',
prior === 'ecazNexus' ? g.turn : 1, prior);
g.dukeVidal!.leader.deaths = 2; g.dukeVidal!.leader.usedAt = 'arrakeen';
const before = structuredClone(g), shared = quote(g, 'ec', 'al');
assert.equal(shared.controller, 'al'); assert.equal(shared.source, 'ecaz'); assert.equal(shared.acquiredTurn, g.turn);
assert.deepEqual(shared.leader, g.dukeVidal!.leader); assert.equal(shared.leader.faction, 'ecaz');
assert.equal(shared.leader.deaths, 2); assert.equal(shared.leader.usedAt, 'arrakeen');
assert.equal(block(g, 'ec', 'al'), null); assert.deepEqual(g, before);
assert.notEqual(shared, g.dukeVidal); assert.notEqual(shared.leader, g.dukeVidal!.leader);
assert.deepEqual(expireDuke(shared, g.turn + 1), shared, 'Unused ordinary acquisition does not inherit the turn-loan expiry');
const loan = acquireDuke(createDukeVidal(), 'al', g.turn, 'ally');
assert.equal(expireDuke(loan, g.turn).controller, null, 'New-alliance loan remains a distinct turn-limited source');
const used = consumeDuke(shared); assert.equal(used.controller, null); assert.equal(used.source, null);
assert.equal(used.leader.usedAt, 'arrakeen'); assert.equal(used.leader.deaths, 2);
shared.leader.deaths = 99; assert.deepEqual(g, before, 'Mutating the detached quote cannot mutate a physical source disc');
assert.deepEqual(g.players.map(p => p.leaders), before.players.map(p => p.leaders));
}
});
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.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.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.');
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.');
rejected(g, 'ec', 'ec');
});
}
void test('shared ordinary Duke acquisition rechecks the current unique Ecaz owner and reciprocal recipient', () => {
for (const change of [
(g: Position) => { seat(g, 'ec').ally = null; },
(g: Position) => { seat(g, 'al').ally = null; },
(g: Position) => { seat(g, 'ec').ally = 'g'; },
(g: Position) => { seat(g, 'al').ally = 'g'; },
(g: Position) => { g.players = g.players.filter(p => p.id !== 'al'); },
(g: Position) => { g.players.push(newPlayer('al', 'Duplicate recipient', 'emperor')); },
(g: Position) => { g.players.push(newPlayer('secondEcaz', 'Duplicate native owner', 'ecaz')); },
(g: Position) => { g.status = 'setup'; },
(g: Position) => { g.turn = 0; },
]) { const g = position(); change(g); rejected(g); }
const g = position(); rejected(g, 'al', 'ec'); rejected(g, 'ec', 'g'); rejected(g, 'ec', 'missing');
const earlier = quote(g, 'ec', 'al'); seat(g, 'al').ally = null; rejected(g);
assert.equal(earlier.controller, 'al', 'An earlier detached quote cannot authorize a now-broken alliance');
});
void test('shared ordinary Duke quote blocks exceptional physical custody with one generic public reason and supplies no revival', () => {
for (const advanced of [false, true]) for (const change of [
(g: Position) => { delete g.dukeVidal; },
(g: Position) => { g.dukeVidal!.leader.dead = true; },
(g: Position) => { g.dukeVidal!.leader.capturedBy = 'g'; },
(g: Position) => { g.dukeVidal!.leader.gholaBy = 'g'; },
(g: Position) => { g.dukeVidal!.leader.concealed = { captor: 'g', controller: 'ec', dead: false, deaths: 0 }; },
(g: Position) => { g.dukeVidal!.captureVisibility = { controller: 'ec', acquiredTurn: 1, source: 'ecaz' }; },
(g: Position) => { g.dukeVidal!.leader.id = 'replacement-disc'; },
(g: Position) => { g.dukeVidal!.leader.faction = 'atreides'; },
(g: Position) => { g.dukeVidal!.leader.strength = 5; },
(g: Position) => { g.dukeVidal!.leader.deaths = -1; },
(g: Position) => { g.dukeVidal!.controller = 'absent'; },
(g: Position) => { g.dukeVidal = acquireDuke(g.dukeVidal!, 'm', 3, 'moritani'); },
]) {
const g = position(advanced); change(g); rejected(g);
assert.equal(g.players.flatMap(p => p.leaders).some(leader => leader.id === 'duke-vidal'), false);
}
});
void test('sharing ordinary acquisition preserves the existing Advanced Harkonnen table boundary and admitted no-Skills profile', () => {
const g = position(true); seat(g, 'g').faction = 'harkonnen';
const reason = block(g, 'ec', 'al'); assert.match(reason!, /Advanced Harkonnen/);
assert.throws(() => quote(g, 'ec', 'al'), error => error instanceof EcazDukeAcquisitionError && error.reason === 'table');
g.dukeVidal!.leader.capturedBy = 'g'; assert.equal(block(g, 'ec', 'al'), reason);
delete g.dukeVidal!.leader.capturedBy;
const basic = structuredClone(g); basic.advanced = false; assert.equal(block(basic, 'ec', 'al'), null);
g.ecazTreachery = true; g.discoveryEnabled = true;
assert.equal(quote(g, 'ec', 'al').controller, 'al'); assert.equal(block(g, 'ec', 'al'), null);
g.leaderSkills = {}; assert.equal(block(g, 'ec', 'al'), reason);
});
+19 -7
View File
@@ -148,12 +148,6 @@ const unavailable: [string, (g: Game) => void][] = [
g.dukeVidal!.leader.deaths = 1;
},
],
[
'already controlled by Ecaz',
(g) => {
g.dukeVidal = acquireDuke(g.dukeVidal!, 'ec', 1, 'ecaz');
},
],
[
'Advanced Harkonnen table remains gated',
(g) => {
@@ -199,7 +193,23 @@ void test('only the actual owner gets the choice, and bots cannot acquire from a
assert.deepEqual(botActions(botView(g, level, id)), []);
}
});
void test('all profiles rely on the server descriptor, with safe decline for a missing descriptor', () => {
void test('all profiles share an Ecaz-held Duke with its offered existing ally', () => {
const state = fixture(false, true);
state.dukeVidal = acquireDuke(state.dukeVidal!, 'ec', 1, 'ecaz');
const g = enter(state);
for (const level of DIFFICULTIES) {
const selected = action(g, level);
assert.deepEqual(selected, { type: 'decision', event: g.pendingAmbassador!.event,
trigger: true, beneficiary: 'al', choice: 'duke' });
const done = applyAction(g, 'ec', selected);
assert.equal(done.dukeVidal!.controller, 'al');
assert.equal(done.dukeVidal!.source, 'ecaz');
assert.deepEqual(done.players, g.players);
assert.equal(done.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!.zone, 'supply');
assert.deepEqual(done.ecazAmbassadors!.cohort, g.ecazAmbassadors!.cohort);
}
});
void test('all profiles rely on offered recipient descriptors, with safe decline when all are missing', () => {
const g = enter(fixture(false, true));
for (const level of DIFFICULTIES) {
const view = botView(g, level);
@@ -210,6 +220,8 @@ void test('all profiles rely on the server descriptor, with safe decline for a m
});
assert.equal(botActions(view)[0].choice, 'duke');
view.ambassadorEntry!.dukeAcquisition = null;
assert.equal(botActions(view)[0].choice, 'duke');
view.ambassadorEntry!.beneficiaries = [];
assert.deepEqual(botActions(view), [
{ type: 'decision', event: g.pendingAmbassador!.event, decline: true },
]);
+171
View File
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { validateNexusCards, NEXUS_FACTIONS } from '../game/nexus-cards';
import { actDukeAmbassador, dukeAmbassadorClean, dukeAmbassadorPlayer,
nextDukeAmbassadorStep } from './fixture-duke-ambassador';
import { advisorPreparationInventory } from './fixture-advisor-preparation';
export type EcazDukeAllyStep = { actor: string; action: Action };
export const actEcazDukeAlly = actDukeAmbassador;
export const ecazDukeAllyClean = dukeAmbassadorClean;
export const ecazDukeAllyPlayer = dukeAmbassadorPlayer;
export function ecazDukeAllyInventory(game: Game) {
const treachery = advisorPreparationInventory(game);
assert.equal(treachery.length, 33, 'The source retains the original base deck without selecting E3 Treachery');
assert.ok(game.nexusCards?.cards);
validateNexusCards(game.nexusCards.cards, game.players);
const cards = game.nexusCards.cards, nexus = [...cards.deck, ...cards.discard,
...Object.values(cards.hands).filter(card => card !== null)].sort();
assert.deepEqual(nexus, [...NEXUS_FACTIONS].sort());
assert.ok(game.players.every(player => !player.leaders.some(leader => leader.id === DUKE_VIDAL_ID)));
assert.ok(!(game.traitorReserve ?? []).includes(DUKE_VIDAL_ID));
assert.ok(game.players.every(player => ![...player.traitors, ...player.traitorChoices].includes(DUKE_VIDAL_ID)));
return { treachery, nexus };
}
export function ecazDukeAllyPhysical(game: Game) {
return { active: game.active, movementRemaining: [...(game.movementRemaining ?? [])],
players: game.players.map(player => ({ id: player.id, spice: player.spice, forces: structuredClone(player.forces),
reserves: player.reserves, tanks: player.tanks, elites: structuredClone(player.elites),
shipped: player.shipped, moved: player.moved })) };
}
export function nextEcazDukeAllyStep(game: Game): EcazDukeAllyStep | null {
if (ecazDukeAllyClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
return nextDukeAmbassadorStep(game);
}
export function advanceEcazDukeAlly(game: Game, until: (state: Game) => boolean,
actions: EcazDukeAllyStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native existing-ally Duke source ended before its owned boundary');
const step = nextEcazDukeAllyStep(game);
assert.ok(step, `Choose the actual ${game.turn}/${game.phase}/${game.decision?.kind} before continuing`);
game = actEcazDukeAlly(game, step.actor, step.action, actions);
}
throw Error('The native existing-ally Duke source did not reach its finite boundary');
}
function orderSpice(game: Game, names: readonly string[]) {
const cards = names.map(name => {
const at = game.spiceDeck.findIndex(card => name === 'worm'
? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed
: 'territory' in card && !card.discovery && card.territory === name);
assert.ok(at >= 0, `The original ${name} Spice Card must remain unopened`);
return game.spiceDeck.splice(at, 1)[0];
});
game.spiceDeck.unshift(...cards);
}
/** Native self-acquisition is deliberately left unused through Mentat. A real
* next-turn Nexus forms the alliance before the reusable token is placed and
* a different nonally entry earns the existing-ally beneficiary offer. */
export function createEcazDukeAllyFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? false;
let game = createGame('ECAZDUKEALLY', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
for (const faction of ['atreides', 'emperor', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const actions: EcazDukeAllyStep[] = [];
const act = (actor: string, action: Action) => { game = actEcazDukeAlly(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceEcazDukeAlly(game, until, actions); };
act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const initial = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), originalInventory = ecazDukeAllyInventory(game);
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz');
assert.ok(token && token.zone === 'supply');
const cohort = [...game.ecazAmbassadors!.cohort];
orderSpice(game, ['broken_land', ...(advanced ? ['south_mesa'] : [])]);
const placements: { before: Game; action: Action; after: Game }[] = [];
const place = (territory: string) => {
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
const before = structuredClone(game), action: Action = { type: 'decision', token: token.id, territory };
act('ecaz', action);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
placements.push({ before, action, after: structuredClone(game) });
act('ecaz', { type: 'decision', decline: true });
};
const shipments: { before: Game; step: EcazDukeAllyStep; after: Game }[] = [];
const ship = (actor: string, territory: string, sector: number, offer: boolean) => {
reach(state => state.phase === 5 && state.active === actor && ecazDukeAllyClean(state));
const before = structuredClone(game), step: EcazDukeAllyStep = { actor,
action: { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 } };
act(actor, step.action);
reach(state => offer
? !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'offer'
: ecazDukeAllyClean(state));
shipments.push({ before, step, after: structuredClone(game) });
};
place('habbanya_ridge_sietch');
ship('emperor', 'habbanya_ridge_sietch', 17, true);
const beforeSelf = structuredClone(game), selfAction: Action = { type: 'decision',
event: game.pendingAmbassador!.event, trigger: true, beneficiary: 'ecaz', choice: 'duke' };
act('ecaz', selfAction); reach(ecazDukeAllyClean);
const afterSelf = structuredClone(game);
assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn], ['ecaz', 'ecaz', 1]);
assert.equal(game.ecazAmbassadors!.tokens.find(current => current.id === token.id)!.zone, 'supply');
reach(state => state.turn === 2 && state.phase === 1 && ecazDukeAllyClean(state));
const afterMentat = structuredClone(game);
assert.equal(game.dukeVidal!.controller, 'ecaz', 'The unused ordinary Ecaz acquisition persists across the turn boundary');
orderSpice(game, ['worm', 'hagga_basin', ...(advanced ? ['red_chasm'] : [])]);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && ecazDukeAllyClean(state));
const beforeAlliance = structuredClone(game), allianceStart = actions.length;
act('ecaz', { type: 'alliance', target: 'atreides' });
act('atreides', { type: 'alliance', target: 'ecaz' });
const alliance = { before: beforeAlliance, actions: structuredClone(actions.slice(allianceStart)), after: structuredClone(game) };
place('carthag');
ship('atreides', 'carthag', 11, false);
ship('emperor', 'carthag', 11, true);
assert.equal(game.turn, 2);
assert.equal(game.pendingAmbassador!.owner, 'ecaz');
assert.equal(game.pendingAmbassador!.entrant, 'emperor');
assert.equal(game.pendingAmbassador!.token, token.id);
assert.equal(game.dukeVidal!.controller, 'ecaz');
const event = game.pendingAmbassador!.event;
const action: Action = { type: 'decision', event, trigger: true, beneficiary: 'atreides', choice: 'duke' };
return { game, advanced, owner: 'ecaz', holder: 'ecaz', ally: 'atreides', recipient: 'atreides', entrant: 'emperor',
opponent: 'emperor', duke: DUKE_VIDAL_ID, token: token.id, event, action, initial, setup, afterSetup,
originalInventory, cohort, placements, shipments, beforeSelf, selfAction, afterSelf, afterMentat, alliance,
originalDuke: structuredClone(game.dukeVidal!), originalParent: structuredClone(game.pendingAmbassador),
originalPhysical: ecazDukeAllyPhysical(game), actions, sourceActionCount: actions.length,
staging: ['Only original unopened Spice positions and native scalar lotteries are selected. Two actual Ecaz-token placements, Emperor entries, native self-acquisition, full unused Mentat, real worm/Nexus alliance and Atreides opposing shipment create this source.'] };
}
export type EcazDukeAllyFixture = ReturnType<typeof createEcazDukeAllyFixture>;
export function grantEcazDukeAlly(fixture: EcazDukeAllyFixture, game = structuredClone(fixture.game),
actions: EcazDukeAllyStep[] = []) {
return actEcazDukeAlly(game, fixture.owner, fixture.action, actions);
}
export function finishEcazDukeAlly(fixture: EcazDukeAllyFixture, game: Game,
actions: EcazDukeAllyStep[] = []) {
return advanceEcazDukeAlly(game, state => state.turn === fixture.game.turn && !state.pendingAmbassador && ecazDukeAllyClean(state), actions);
}
export function battleEcazDukeAlly(fixture: EcazDukeAllyFixture, game: Game,
actions: EcazDukeAllyStep[] = [], options: { useDuke?: boolean } = {}) {
game = advanceEcazDukeAlly(game, state => state.phase === 6 && ecazDukeAllyClean(state), actions);
assert.ok(game.active === fixture.ally || game.active === fixture.opponent);
const chooser = game.active;
game = actEcazDukeAlly(game, chooser, { type: 'chooseBattle', territory: 'carthag',
target: chooser === fixture.ally ? fixture.opponent : fixture.ally }, actions);
game = advanceEcazDukeAlly(game, state => !!state.battle && (!state.battle.preLeader || state.battle.preLeader.closed) &&
!state.battle.preparation && ecazDukeAllyClean(state), actions);
const beforePlans = structuredClone(game), opponent = [...ecazDukeAllyPlayer(game, fixture.opponent).leaders]
.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0];
const native = [...ecazDukeAllyPlayer(game, fixture.ally).leaders]
.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0];
const useDuke = options.useDuke ?? true;
assert.ok(opponent && native && (useDuke ? 6 : native.strength) > opponent.strength);
const plans: EcazDukeAllyStep[] = [
{ actor: fixture.ally, action: { type: 'battlePlan', leader: useDuke ? DUKE_VIDAL_ID : native.id, dial: 0, support: 0 } },
{ actor: fixture.opponent, action: { type: 'battlePlan', leader: opponent.id, dial: 0, support: 0 } },
];
for (const step of plans) game = actEcazDukeAlly(game, step.actor, step.action, actions);
const sealed = structuredClone(game);
game = advanceEcazDukeAlly(game, state => !state.battle && ecazDukeAllyClean(state), actions);
return { game, beforePlans, sealed, plans, actions };
}
+12
View File
@@ -33,6 +33,18 @@ function position(): Game {
} as unknown as Game;
}
void test('Cunning validates captured ordinary Ambassador tenure held by a former ally recipient', () => {
// Controlled custody input, separate from the native token/alliance source.
const g = position();
const shared = acquireDuke(g.dukeVidal!, 'g', 2, 'ecaz');
g.dukeVidal = captureDuke(shared, 'h', 'g', 3, 'carthag');
assert.equal(quoteNexusEcazDuke(g, 'e')!.blocked, null);
const receipt = createNexusEcazDukeReceipt(g, 'e');
assert.equal(receipt.before.controller, 'g');
assert.equal(receipt.before.source, 'ecaz');
assert.doesNotThrow(() => validateNexusEcazDuke(g, receipt, 0));
});
void test('Cunning sees actual capture over its own underlying Ecaz tenure and liberates it for this turn only', () => {
const g = position();
const owned = acquireDuke(g.dukeVidal!, 'e', 1, 'ecaz');
+18 -1
View File
@@ -30,6 +30,21 @@ function game(advanced = false): Game {
} as unknown as Game;
}
void test('Ecaz Cunning validates an ordinary Ambassador acquisition held by another seated recipient', () => {
// Controlled prior custody: ordinary sharing can outlast the earlier alliance.
const g = game();
g.dukeVidal = acquireDuke(g.dukeVidal!, 'atreides-seat', 2, 'ecaz');
assert.equal(quoteNexusEcazDuke(g, 'ecaz-seat')!.blocked, null);
const record = createNexusEcazDukeReceipt(g, 'ecaz-seat');
assert.equal(record.before.controller, 'atreides-seat');
assert.equal(record.before.source, 'ecaz');
g.dukeVidal = acquireDuke(g.dukeVidal!, 'ecaz-seat', g.turn, 'ecazNexus');
g.nexusCards!.cards = discardNexusCard(g.nexusCards!.cards!, 'ecaz-seat', g.players);
g.nexusEcazDukeHistory = [record];
g.nexusEcazDukeEvents = [record.event];
assert.doesNotThrow(() => validateNexusEcazDuke(g, record, 0));
});
void test('native physical Ecaz Cunning overrides Moritani at a quiet Battle boundary', () => {
const g = game(true);
g.dukeVidal = acquireDuke(g.dukeVidal!, 'moritani-seat', 3, 'moritani');
@@ -253,7 +268,9 @@ void test('damaged and re-signed impossible history cannot forge the original Du
invalid(record => { record.before.acquiredTurn = 4; });
invalid(record => { record.before.acquiredTurn = 2; });
invalid(record => { record.before.source = null; });
invalid(record => { record.before.source = 'ecaz'; });
// Ordinary Ecaz tenure may now belong to a shared recipient; Nexus tenure
// still cannot be forged for a non-Ecaz controller.
invalid(record => { record.before.source = 'ecazNexus'; });
invalid(record => {
record.before.acquiredTurn = 2;
record.event = nexusEcazDukeEvent(3, 6, 'ecaz-seat', 0, 'moritani-seat', 2);