Connect Ecaz Duke revival to the Tleilaxu Ambassador
The original own-leader grant omitted the shared Duke because it searched only native rosters and KH. Offer Duke only to Ecaz using canonical dead-disc identity/custody validation and the existing independent permission. Preserve ordinary first-death/KH limits and unsupported source envelopes. Native Tleilaxu and Bene Gesserit copied effects share the choice. Return the actual shared disc alive and set aside, preserve strength and death history, clear obsolete use/concealment and record any satisfied native cohort opening before clearing death. Spend no spice, Ghola or ordinary leader allowance; add no native disc or skill. Finish the same committed Ambassador and retain original entrant movement. Persistent guidance explains that a separate legal acquisition is still needed. Source-specific tests, genuine public Basic acquisition/death/arrival programmes, actual GameTable/entry rendering and four existing minimal profiles verify the consumer. Controlled Advanced/copy/repeated/cohort/ownership/event probes remain separate. Exact frozen checks and limits are in docs/TLEILAXU_AMBASSADOR_RULES.md. This composes the printed grant with the selected set-aside/independent Duke policy; it does not settle generic ordinary repeat/income policy, open a module, certify full modes or claim browser/deployed/final assurance. No strategic AI refinement or saved-room/database/reset/restart/deployment operation.
This commit is contained in:
1 parent
c95e294890
commit
59aa56af2b
12 files changed
+742
-20
No files matched your search
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createAmbassadors, validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { botActions } from '../game/bots';
|
||||
import { actDukeAmbassador, createDukeAmbassadorFixture } from './fixture-duke-ambassador';
|
||||
|
||||
const player = (g: Game, id = 'ecaz') => g.players.find(p => p.id === id)!;
|
||||
const reject = (g: Game, actor: string, action: Action) => {
|
||||
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
|
||||
};
|
||||
/** Controlled consumer probes. Original-action source proof is separate. */
|
||||
function fixture({ advanced = false, copied = false, deaths = 1 } = {}) {
|
||||
const g = createGame('DUKEAMBPROBE', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
|
||||
g.players.push(newPlayer('entrant', 'Entrant', 'guild'), newPlayer('ally', 'Ally', 'atreides'));
|
||||
Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18,
|
||||
active: 'entrant', order: ['entrant', 'ecaz', 'ally'], movementRemaining: ['entrant', 'ecaz', 'ally'], deck: baseDeck() });
|
||||
for (const p of g.players) { p.hand = []; p.forces = {}; p.reserves = 20; p.spice = 12; }
|
||||
g.dukeVidal = createDukeVidal(); Object.assign(g.dukeVidal.leader, { dead: true, deaths, usedAt: 'imperial_basin' });
|
||||
const state = createAmbassadors(() => 0), effect = copied ? 'beneGesserit' : 'tleilaxu';
|
||||
const cohort = [effect, ...state.tokens.map(t => t.effect).filter(e => e !== effect && e !== 'ecaz')].slice(0, 5);
|
||||
state.cohort = state.tokens.filter(t => cohort.includes(t.effect)).map(t => t.id);
|
||||
for (const token of state.tokens) {
|
||||
token.zone = token.effect === 'ecaz' || state.cohort.includes(token.id) ? 'supply' : 'pool';
|
||||
token.location = null;
|
||||
}
|
||||
const token = state.tokens.find(t => t.effect === effect)!;
|
||||
token.zone = 'placed'; token.location = 'arrakeen'; validateAmbassadors(state);
|
||||
g.ecazAmbassadors = state;
|
||||
return g;
|
||||
}
|
||||
function pass(g: Game) {
|
||||
for (let n = 0; g.response && n < 30; n++) {
|
||||
const actor = g.players.find(p => !g.response!.passed.includes(p.id))!;
|
||||
g = applyAction(g, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(g.response, null); return g;
|
||||
}
|
||||
function offer(source: Game, beneficiary = 'ecaz', copied = false) {
|
||||
let g = pass(applyAction(source, 'entrant', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 }));
|
||||
if (g.decision?.kind === 'guildShipment')
|
||||
g = pass(applyAction(g, g.decision.player, { type: 'decision', allow: true }));
|
||||
assert.equal(g.pendingAmbassador?.stage, 'offer');
|
||||
const event = g.pendingAmbassador!.event;
|
||||
g = applyAction(g, 'ecaz', { type: 'decision', event, trigger: true, beneficiary, choice: 'effect' });
|
||||
if (copied) {
|
||||
assert.equal(g.pendingAmbassador?.stage, 'copy');
|
||||
g = applyAction(g, beneficiary, { type: 'decision', event, effect: 'tleilaxu' });
|
||||
}
|
||||
assert.equal(g.pendingAmbassador?.stage, 'revival'); return g;
|
||||
}
|
||||
const balances = (g: Game) => g.players.map(p => ({ id: p.id, spice: p.spice,
|
||||
reserves: p.reserves, tanks: p.tanks, forces: p.forces, hand: p.hand,
|
||||
revived: p.revived, freeForcesRevived: p.freeForcesRevived, leaderRevived: p.leaderRevived }));
|
||||
function revive(g: Game) {
|
||||
return applyAction(g, 'ecaz', { type: 'decision', event: g.pendingAmbassador!.event, leader: DUKE_VIDAL_ID });
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`controlled ${advanced ? 'Advanced' : 'Basic'} Ambassador returns the actual Duke alive and set aside without price or ordinary allowance`, () => {
|
||||
const g = offer(fixture({ advanced })), before = structuredClone(g), own = viewGame(g, 'ecaz').ambassadorEntry!.revival!;
|
||||
assert.ok(own.leaders.some(l => l.id === DUKE_VIDAL_ID));
|
||||
assert.equal(own.maximum, 0); assert.equal(viewGame(g, 'entrant').ambassadorEntry!.revival, null);
|
||||
const result = revive(g);
|
||||
assert.deepEqual(balances(result), balances(before));
|
||||
assert.equal(result.dukeVidal!.leader.id, DUKE_VIDAL_ID); assert.equal(result.dukeVidal!.leader.strength, 6);
|
||||
assert.equal(result.dukeVidal!.leader.dead, false); assert.equal(result.dukeVidal!.leader.deaths, 1);
|
||||
assert.equal(result.dukeVidal!.leader.usedAt, undefined); assert.equal(result.dukeVidal!.controller, null);
|
||||
assert.equal(result.dukeVidal!.source, null); assert.equal(result.dukeVidal!.acquiredTurn, null);
|
||||
assert.ok(result.players.every(p => !p.leaders.some(l => l.id === DUKE_VIDAL_ID)));
|
||||
assert.equal(player(result).leaders.length, 5); assert.ok(!result.pendingRevival);
|
||||
assert.equal(result.pendingAmbassador, null); assert.equal(result.active, 'entrant');
|
||||
assert.equal(player(result, 'entrant').forces['arrakeen:10'], 1);
|
||||
assert.ok(result.log.some(l => /living shared disc is set aside, unclaimed/.test(l.text)));
|
||||
reject(result, 'ecaz', { type: 'decision', event: before.pendingAmbassador!.event, leader: DUKE_VIDAL_ID });
|
||||
const ended = applyAction(result, 'entrant', { type: 'endMovement' });
|
||||
assert.equal(ended.movementRemaining?.includes('entrant') ?? false, false);
|
||||
});
|
||||
|
||||
void test('controlled copied Tleilaxu grant keeps Duke identity and the Ecaz-only beneficiary rule', () => {
|
||||
const copied = offer(fixture({ copied: true }), 'ecaz', true);
|
||||
assert.equal(revive(copied).dukeVidal!.leader.dead, false);
|
||||
const source = fixture(); player(source).ally = 'ally'; player(source, 'ally').ally = 'ecaz';
|
||||
const allied = offer(source, 'ally');
|
||||
assert.equal(viewGame(allied, 'ally').ambassadorEntry!.revival!.leaders.some(l => l.id === DUKE_VIDAL_ID), false);
|
||||
reject(allied, 'ally', { type: 'decision', event: allied.pendingAmbassador!.event, leader: DUKE_VIDAL_ID });
|
||||
const declined = applyAction(allied, 'ally', { type: 'decision', event: allied.pendingAmbassador!.event, decline: true });
|
||||
assert.equal(declined.dukeVidal!.leader.dead, true); assert.equal(declined.pendingAmbassador, null);
|
||||
});
|
||||
|
||||
void test('controlled independent Duke return preserves repeated history and a satisfied native cohort opening', () => {
|
||||
const source = fixture({ deaths: 3 }); player(source).leaderRevived = true;
|
||||
for (const leader of player(source).leaders.slice(0, 4)) Object.assign(leader, { dead: true, deaths: 1 });
|
||||
const g = offer(source), before = structuredClone(g), done = revive(g);
|
||||
assert.equal(done.dukeVidal!.leader.deaths, 3); assert.equal(done.dukeVidal!.leader.dead, false);
|
||||
assert.equal(player(done).leaderRevived, true); assert.deepEqual(balances(done), balances(before));
|
||||
assert.equal(player(done).ecazRevivalCohort?.cycle, 1);
|
||||
assert.deepEqual(player(done).ecazRevivalCohort?.returned, []);
|
||||
assert.equal(player(done).leaders.filter(l => l.dead).length, 4);
|
||||
});
|
||||
|
||||
void test('controlled Duke grant uses the current event and cannot also return forces or act for another beneficiary', () => {
|
||||
const g = offer(fixture()), event = g.pendingAmbassador!.event;
|
||||
reject(g, 'ecaz', { type: 'decision', event: 'old-event', leader: DUKE_VIDAL_ID });
|
||||
reject(g, 'entrant', { type: 'decision', event, leader: DUKE_VIDAL_ID });
|
||||
reject(g, 'ecaz', { type: 'decision', event, leader: DUKE_VIDAL_ID, forces: 1 });
|
||||
const declined = applyAction(g, 'ecaz', { type: 'decision', event, decline: true });
|
||||
assert.equal(declined.dukeVidal!.leader.dead, true); assert.equal(declined.pendingAmbassador, null);
|
||||
});
|
||||
|
||||
void test('controlled Duke return requires a separate original Ambassador acquisition before Ecaz controls him', () => {
|
||||
const source = fixture(), token = source.ecazAmbassadors!.tokens.find(t => t.effect === 'ecaz')!;
|
||||
token.zone = 'placed'; token.location = 'carthag'; validateAmbassadors(source.ecazAmbassadors!);
|
||||
const revived = revive(offer(source)); assert.equal(revived.dukeVidal!.controller, null);
|
||||
let arrived = pass(applyAction(revived, 'entrant', { type: 'move', from: 'arrakeen:10', amount: 1, territory: 'carthag', sector: 11 }));
|
||||
assert.equal(arrived.pendingAmbassador?.token, token.id); assert.equal(arrived.pendingAmbassador?.stage, 'offer');
|
||||
const event = arrived.pendingAmbassador!.event;
|
||||
arrived = applyAction(arrived, 'ecaz', { type: 'decision', event, trigger: true, beneficiary: 'ecaz', choice: 'duke' });
|
||||
assert.equal(arrived.dukeVidal!.controller, 'ecaz'); assert.equal(arrived.dukeVidal!.source, 'ecaz');
|
||||
assert.equal(arrived.dukeVidal!.leader.dead, false); assert.equal(arrived.dukeVidal!.leader.deaths, 1);
|
||||
assert.ok(arrived.players.every(p => !p.leaders.some(l => l.id === DUKE_VIDAL_ID)));
|
||||
});
|
||||
|
||||
void test('all four existing profiles choose a legal sole Duke return and resume the original entrant', () => {
|
||||
const source = offer(fixture());
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(source, 'ecaz'); view.players.find(p => p.id === 'ecaz')!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.equal(action.leader, DUKE_VIDAL_ID);
|
||||
const done = applyAction(source, 'ecaz', action);
|
||||
assert.equal(done.dukeVidal!.leader.dead, false); assert.equal(done.dukeVidal!.controller, null);
|
||||
assert.equal(done.active, 'entrant'); assert.equal(done.pendingAmbassador, null);
|
||||
}
|
||||
});
|
||||
|
||||
for (const copied of [false, true]) void test(`genuine Basic ${copied ? 'copied' : 'native'} Ambassador revives the original battle-killed Duke and resumes paid Guild entry`, () => {
|
||||
const f = createDukeAmbassadorFixture({ copied }), g = f.game;
|
||||
assert.equal(g.advanced, false); assert.deepEqual(g.expansions, ['ecaz']);
|
||||
assert.ok(f.actions.some(step => step.action.type === 'start'));
|
||||
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
|
||||
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(c => c.id), f.deal);
|
||||
assert.equal(f.acquisition.after.dukeVidal!.controller, f.owner);
|
||||
assert.equal(f.afterDeath.dukeVidal!.leader.dead, true);
|
||||
assert.equal(f.afterDeath.dukeVidal!.leader.deaths, 1);
|
||||
assert.equal(player(g).tanks, 0); assert.ok(player(g).leaders.every(l => !l.dead));
|
||||
assert.deepEqual(viewGame(g, f.owner).ambassadorEntry!.revival!.leaders.map(l => l.id), [DUKE_VIDAL_ID]);
|
||||
assert.equal(viewGame(g, f.entrant).ambassadorEntry!.revival, null);
|
||||
const done = actDukeAmbassador(g, f.owner, { type: 'decision', event: f.event, leader: DUKE_VIDAL_ID });
|
||||
assert.deepEqual(balances(done), balances(g));
|
||||
assert.equal(done.dukeVidal!.leader.dead, false); assert.equal(done.dukeVidal!.leader.deaths, 1);
|
||||
assert.equal(done.dukeVidal!.controller, null); assert.equal(done.pendingAmbassador, null);
|
||||
assert.equal(done.active, f.entrant); assert.equal(player(done, f.entrant).forces[f.location], 1);
|
||||
assert.deepEqual([...done.deck, ...done.discard, ...done.players.flatMap(p => p.hand)].map(c => c.id).sort(), f.originalTreachery);
|
||||
const finished = actDukeAmbassador(done, f.entrantContinuation.actor, f.entrantContinuation.action);
|
||||
assert.equal(finished.movementRemaining?.includes(f.entrant) ?? false, false);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(g, f.owner); view.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.equal(action.leader, DUKE_VIDAL_ID);
|
||||
const revived = actDukeAmbassador(g, f.owner, action);
|
||||
assert.equal(revived.dukeVidal!.leader.dead, false); assert.equal(revived.dukeVidal!.controller, null);
|
||||
assert.equal(revived.pendingAmbassador, null); assert.deepEqual(balances(revived), balances(g));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type DukeAmbassadorStep = { actor: string; action: Action };
|
||||
export type DukeAmbassadorTransition = { before: Game; step: DukeAmbassadorStep; after: Game };
|
||||
export const dukeAmbassadorPlayer = (game: Game, actor = 'ecaz') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const dukeAmbassadorClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Select original scalar lotteries only. UUID and other byte entropy remains
|
||||
* native. The optional nine values belong solely to original packet creation. */
|
||||
function withDukeAmbassadorLottery<T>(run: () => T, packet: readonly number[] = []): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1)
|
||||
array[0] = cursor < packet.length ? packet[cursor++] : 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const result = run(); assert.equal(cursor, packet.length, 'The original Ambassador packet consumes its nine selected draws');
|
||||
return result;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actDukeAmbassador(game: Game, actor: string, action: Action,
|
||||
actions: DukeAmbassadorStep[] = []): Game {
|
||||
const next = withDukeAmbassadorLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
|
||||
/** The owned programme supplies all Ambassador choices and sealed plans. This
|
||||
* driver passes native responses, declines further paid placements and makes
|
||||
* original public readiness/end-of-movement choices without optional spending. */
|
||||
export function nextDukeAmbassadorStep(game: Game): DukeAmbassadorStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
if (game.decision?.kind === 'ecazAmbassador') return null;
|
||||
if (game.decision?.kind === 'ecazPlacement')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && dukeAmbassadorPlayer(game).reserves === 20)
|
||||
return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceDukeAmbassador(game: Game, until: (state: Game) => boolean,
|
||||
actions: DukeAmbassadorStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Duke programme ended before its next control');
|
||||
const next = nextDukeAmbassadorStep(game);
|
||||
assert.ok(next, `Choose the actual Ambassador effect or sealed plan before advancing: ${JSON.stringify({
|
||||
turn: game.turn, phase: game.phase, active: game.active, decision: game.decision,
|
||||
entry: game.pendingAmbassador ? { stage: game.pendingAmbassador.stage, entrant: game.pendingAmbassador.entrant,
|
||||
territory: game.pendingAmbassador.territory, effect: game.pendingAmbassador.effect } : null,
|
||||
})}`);
|
||||
game = actDukeAmbassador(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Duke Ambassador programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Fisher–Yates draws for the original ten-token pool. The native token IDs,
|
||||
* zones and cohort are created by initializeStartingForces, never rewritten. */
|
||||
function originalPacket(copied: boolean) {
|
||||
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = copied
|
||||
? ['beneGesserit', 'atreides', 'choam', 'emperor', 'fremen']
|
||||
: ['tleilaxu', 'atreides', 'choam', 'emperor', 'fremen'];
|
||||
const desired = [...supply, ...pool.filter(effect => !supply.includes(effect))];
|
||||
const current = [...pool], rolls: number[] = [];
|
||||
for (let i = current.length - 1; i > 0; i--) {
|
||||
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
}
|
||||
assert.deepEqual(current, desired); return { rolls, supply };
|
||||
}
|
||||
|
||||
/** Genuine public Basic E3 source. Two distinct original poison cards kill
|
||||
* both leaders in one real Carthag battle. Ecaz is the native attacker and
|
||||
* wins the zero-strength tie with dial zero, so none of its forces or native
|
||||
* leaders enter the Tanks. Only the separate shared Duke needs revival.
|
||||
*
|
||||
* Stop BEFORE selecting the newly supported Duke grant. This fixture does not
|
||||
* install that selection, a death, a token, a wallet, a turn or an arrival. */
|
||||
export function createDukeAmbassadorFixture(options: { copied?: boolean } = {}) {
|
||||
const copied = options.copied ?? false, packet = originalPacket(copied);
|
||||
let game = createGame('DUKEAMBASSADOR', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz']);
|
||||
for (const faction of ['harkonnen', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: DukeAmbassadorStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actDukeAmbassador(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceDukeAmbassador(game, until, actions); };
|
||||
const transition = (step: DukeAmbassadorStep, until: (state: Game) => boolean): DukeAmbassadorTransition => {
|
||||
const before = structuredClone(game); act(step.actor, step.action); reach(until);
|
||||
return { before, step: structuredClone(step), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalTreachery = game.deck.map(card => card.id).sort();
|
||||
const poisons = game.deck.filter(card => card.kind === 'poison').slice(0, 2);
|
||||
const fillers = game.deck.filter(card => card.kind === 'worthless').slice(0, 2);
|
||||
assert.equal(poisons.length, 2); assert.equal(fillers.length, 2);
|
||||
const deal = [poisons[0].id, poisons[1].id, fillers[0].id, fillers[1].id];
|
||||
const dealOrder = deal.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let packetCreation: DukeAmbassadorTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 80; count++) {
|
||||
const next = nextDukeAmbassadorStep(game); assert.ok(next);
|
||||
const initializesForces = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
|
||||
game.players.filter(player => player.traitorChoices.length > 0).length === 1;
|
||||
if (initializesForces) {
|
||||
const before = structuredClone(game);
|
||||
game = withDukeAmbassadorLottery(() => applyAction(game, next.actor, next.action), packet.rolls);
|
||||
actions.push(structuredClone(next));
|
||||
packetCreation = { before, step: structuredClone(next), after: structuredClone(game) };
|
||||
} else act(next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(packetCreation);
|
||||
const afterSetup = structuredClone(game);
|
||||
const owner = 'ecaz', opponent = 'harkonnen', entrant = 'guild';
|
||||
const ecazToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
|
||||
const revivalToken = game.ecazAmbassadors!.tokens.find(token => token.effect === (copied ? 'beneGesserit' : 'tleilaxu'))!;
|
||||
assert.equal(ecazToken.zone, 'supply'); assert.equal(revivalToken.zone, 'supply');
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.effect === 'tleilaxu')!.zone, copied ? 'pool' : 'supply');
|
||||
assert.ok(dukeAmbassadorPlayer(game).hand.some(card => card.id === poisons[0].id));
|
||||
assert.ok(dukeAmbassadorPlayer(game, opponent).hand.some(card => card.id === poisons[1].id));
|
||||
// The original first two unopened land cards replace no live encounter and
|
||||
// keep both turns free of a worm/alliance detour. All Spice cards are retained.
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.turn === 1 && state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const placements = [
|
||||
transition({ actor: owner, action: { type: 'decision', token: ecazToken.id, territory: 'arrakeen' } },
|
||||
state => !state.response && state.decision?.kind === 'ecazPlacement'),
|
||||
transition({ actor: owner, action: { type: 'decision', token: revivalToken.id, territory: 'habbanya_ridge_sietch' } },
|
||||
state => !state.response && state.decision?.kind === 'ecazPlacement'),
|
||||
];
|
||||
assert.equal(placements[0].after.players.find(player => player.id === owner)!.spice,
|
||||
placements[0].before.players.find(player => player.id === owner)!.spice - 1);
|
||||
assert.equal(placements[1].after.players.find(player => player.id === owner)!.spice,
|
||||
placements[1].before.players.find(player => player.id === owner)!.spice - 2);
|
||||
act(owner, { type: 'decision', decline: true });
|
||||
reach(state => state.phase === 5 && state.active === owner && dukeAmbassadorClean(state));
|
||||
const ownShipment = transition({ actor: owner, action: { type: 'ship', amount: 1, territory: 'carthag', sector: 11 } }, dukeAmbassadorClean);
|
||||
reach(state => state.phase === 5 && state.active === opponent && dukeAmbassadorClean(state));
|
||||
const acquisitionArrival = transition({ actor: opponent, action: { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 } },
|
||||
state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
assert.equal(game.pendingAmbassador!.token, ecazToken.id);
|
||||
const acquisition = transition({ actor: owner, action: { type: 'decision', event: game.pendingAmbassador!.event,
|
||||
trigger: true, beneficiary: owner, choice: 'duke' } }, dukeAmbassadorClean);
|
||||
assert.equal(game.dukeVidal!.controller, owner); assert.equal(game.dukeVidal!.source, 'ecaz');
|
||||
assert.equal(game.dukeVidal!.leader.dead, false);
|
||||
reach(state => state.phase === 6 && !state.battle && dukeAmbassadorClean(state));
|
||||
assert.equal(game.active, owner, 'The original first player circle gives Ecaz this battle choice');
|
||||
const beforeBattle = structuredClone(game), battleStep: DukeAmbassadorStep = {
|
||||
actor: owner, action: { type: 'chooseBattle', territory: 'carthag', target: opponent },
|
||||
};
|
||||
act(battleStep.actor, battleStep.action);
|
||||
reach(state => !!state.battle && dukeAmbassadorClean(state) && !state.battle.preparation &&
|
||||
(!state.battle.preLeader || state.battle.preLeader.closed));
|
||||
assert.equal(game.battle!.attacker, owner);
|
||||
const enemyLeader = dukeAmbassadorPlayer(game, opponent).leaders.filter(leader => !leader.dead && !leader.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemyLeader);
|
||||
const planSteps: DukeAmbassadorStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 0, support: 0, weapon: poisons[0].id } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 0, support: 0, weapon: poisons[1].id } },
|
||||
];
|
||||
for (const step of planSteps) {
|
||||
act(step.actor, step.action);
|
||||
reach(state => !state.response && !state.phaseOpening && !state.decision);
|
||||
}
|
||||
const revealed = structuredClone(game);
|
||||
reach(state => !state.battle && dukeAmbassadorClean(state));
|
||||
const afterDeath = structuredClone(game);
|
||||
assert.equal(game.dukeVidal!.leader.dead, true); assert.equal(game.dukeVidal!.leader.deaths, 1);
|
||||
assert.equal(game.dukeVidal!.controller, null);
|
||||
assert.equal(dukeAmbassadorPlayer(game).tanks, 0);
|
||||
assert.ok(dukeAmbassadorPlayer(game).leaders.every(leader => !leader.dead));
|
||||
assert.equal(dukeAmbassadorPlayer(game).forces['carthag:11'], 1);
|
||||
assert.equal(dukeAmbassadorPlayer(game, opponent).leaders.find(leader => leader.id === enemyLeader.id)!.dead, true);
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && dukeAmbassadorClean(state));
|
||||
const beforeArrival = structuredClone(game);
|
||||
assert.equal(dukeAmbassadorPlayer(game, entrant).shipped, false);
|
||||
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === revivalToken.id)!.zone, 'placed');
|
||||
const arrival = transition({ actor: entrant, action: { type: 'ship', amount: 1,
|
||||
territory: 'habbanya_ridge_sietch', sector: 17 } },
|
||||
state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
const offer = structuredClone(game), event = game.pendingAmbassador!.event;
|
||||
assert.equal(game.pendingAmbassador!.token, revivalToken.id);
|
||||
const triggerStep: DukeAmbassadorStep = { actor: owner,
|
||||
action: { type: 'decision', event, trigger: true, beneficiary: owner, choice: 'effect' } };
|
||||
act(triggerStep.actor, triggerStep.action);
|
||||
let beforeCopy: Game | null = null, copyStep: DukeAmbassadorStep | null = null;
|
||||
if (copied) {
|
||||
assert.equal(game.pendingAmbassador?.stage, 'copy'); beforeCopy = structuredClone(game);
|
||||
copyStep = { actor: owner, action: { type: 'decision', event, effect: 'tleilaxu' } };
|
||||
act(copyStep.actor, copyStep.action);
|
||||
}
|
||||
assert.equal(game.pendingAmbassador?.stage, 'revival');
|
||||
assert.equal(game.pendingAmbassador!.beneficiary, owner); assert.equal(game.pendingAmbassador!.entrant, entrant);
|
||||
assert.equal(game.pendingAmbassador!.effect, 'tleilaxu'); assert.equal(game.decision?.player, owner);
|
||||
assert.equal(game.active, entrant); assert.equal(game.turn, 2);
|
||||
assert.equal(dukeAmbassadorPlayer(game).tanks, 0);
|
||||
assert.ok(dukeAmbassadorPlayer(game).leaders.every(leader => !leader.dead));
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
|
||||
.map(card => card.id).sort(), originalTreachery);
|
||||
const currentGrant = structuredClone(game), ownerView = viewGame(game, owner), entrantView = viewGame(game, entrant);
|
||||
return { game, lobby, setup, beforeStartingDeal, afterSetup, packetCreation, packet, deal, dealOrder,
|
||||
spiceOrder, originalTreachery, placements, ownShipment, acquisitionArrival, acquisition,
|
||||
beforeBattle, battleStep, planSteps, revealed, afterDeath, beforeArrival, arrival, offer,
|
||||
triggerStep, beforeCopy, copyStep, currentGrant, ownerView, entrantView,
|
||||
copied, advanced: false as const, owner, beneficiary: owner, opponent, entrant,
|
||||
duke: DUKE_VIDAL_ID, enemyLeader: enemyLeader.id, token: revivalToken.id, acquisitionToken: ecazToken.id,
|
||||
event, territory: 'habbanya_ridge_sietch', location: 'habbanya_ridge_sietch:17',
|
||||
poison: { ecaz: poisons[0].id, harkonnen: poisons[1].id },
|
||||
entrantContinuation: { actor: entrant, action: { type: 'endMovement' } } as DukeAmbassadorStep,
|
||||
actions, sourceActionCount: actions.length,
|
||||
staging: ['Only four unopened starting-deal positions, two unopened ordinary land Spice positions and original scalar lotteries are selected. Nine explicitly recorded draws select the actual initial Ambassador packet during the final original traitor choice. All other scalar draws retain original Fisher–Yates order; native UUID/byte entropy remains native. Public Basic Ecaz/Harkonnen/Guild setup, paid 1+2 Ambassador placements, paid reserve shipments, actual Ecaz-token Duke acquisition and two sealed poison plans produce the first Duke death. Ecaz wins the 0–0 attacker tie with dial 0 and keeps its physical force, naturally leaving no own force or native-leader revival alternative. Next turn Guild really pays and enters the already placed Tleilaxu or BG token. Stop at the native revival grant before choosing Duke. No dealt hand, army, wallet, clock, death, alliance, token, pending reaction or receipt is installed. Advanced with this Harkonnen roster is outside this source fixture.'] };
|
||||
}
|
||||
export type DukeAmbassadorFixture = ReturnType<typeof createDukeAmbassadorFixture>;
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { leaders } from '../game/cards';
|
||||
import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import {
|
||||
tleilaxuAmbassadorLeaderChoices,
|
||||
tleilaxuAmbassadorLeaderSelection,
|
||||
} from '../game/tleilaxu-ambassador-leaders';
|
||||
|
||||
function fixture(advanced = false, deaths = 1) {
|
||||
const player = { id: 'ec', faction: 'ecaz' as const, leaders: leaders('ecaz'), battleLosses: 0 };
|
||||
const context = {
|
||||
status: 'playing' as const, turn: 3, advanced,
|
||||
players: [player, { id: 'other', faction: 'emperor' as const, leaders: leaders('emperor'), battleLosses: 0 }],
|
||||
dukeVidal: createDukeVidal(),
|
||||
};
|
||||
context.dukeVidal.leader.dead = true;
|
||||
context.dukeVidal.leader.deaths = deaths;
|
||||
return { context, player };
|
||||
}
|
||||
|
||||
// These are pure identity/custody inputs. Actual Ambassador acquisition, free
|
||||
// return, cohort recording and native arrival continuation belong to engine tests.
|
||||
void test('Basic and Advanced Ecaz receive the canonical shared Duke alternative with set-aside destination', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const { context, player } = fixture(advanced), before = structuredClone(context);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, DUKE_VIDAL_ID), DUKE_VIDAL_ID);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, player), [{
|
||||
id: DUKE_VIDAL_ID, name: 'Duke Prad Vidal', strength: 6, destination: 'setAside',
|
||||
}]);
|
||||
assert.deepEqual(context, before);
|
||||
assert.equal(player.leaders.length, 5, 'The shared disc is never appended to the native roster');
|
||||
assert.equal(context.dukeVidal.leader.dead, true, 'Quoting a choice does not revive or transfer the disc');
|
||||
}
|
||||
});
|
||||
|
||||
void test('independent Duke permission preserves repeated deaths without extending ordinary Ambassador repeat returns', () => {
|
||||
for (const deaths of [2, 4]) {
|
||||
const { context, player } = fixture(false, deaths);
|
||||
const first = player.leaders[0], repeated = player.leaders[1];
|
||||
first.dead = true; first.deaths = 1;
|
||||
repeated.dead = true; repeated.deaths = 2;
|
||||
context.dukeVidal.controller = 'ec';
|
||||
context.dukeVidal.source = 'ecaz';
|
||||
context.dukeVidal.acquiredTurn = 1;
|
||||
context.dukeVidal.leader.usedAt = 'carthag';
|
||||
const before = structuredClone(context);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, DUKE_VIDAL_ID), DUKE_VIDAL_ID);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, first.id), first);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, player, repeated.id), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, player).map(choice => choice.id), [first.id, DUKE_VIDAL_ID]);
|
||||
assert.deepEqual(context, before);
|
||||
assert.equal(context.dukeVidal.leader.deaths, deaths);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a non-Ecaz beneficiary or unseated Ecaz identity cannot inherit shared Duke revival', () => {
|
||||
const { context, player } = fixture();
|
||||
for (const faction of ['moritani', 'tleilaxu', 'emperor'] as const) {
|
||||
const other = { id: 'other', faction, leaders: leaders(faction), battleLosses: 0 };
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, other, DUKE_VIDAL_ID), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, other), []);
|
||||
}
|
||||
const absent = { ...player, id: 'absent' };
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(context, absent, DUKE_VIDAL_ID), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices(context, absent), []);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: false }, player, DUKE_VIDAL_ID), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ advanced: false }, player), []);
|
||||
});
|
||||
|
||||
void test('living, unavailable and malformed Duke sources uniformly produce no exceptional choice', () => {
|
||||
const changes: ((value: ReturnType<typeof fixture>) => void)[] = [
|
||||
value => { value.context.dukeVidal.leader.dead = false; },
|
||||
value => { value.context.dukeVidal.leader.deaths = 0; },
|
||||
value => { value.context.dukeVidal.leader.deaths = 1.5; },
|
||||
value => { value.context.dukeVidal.leader.strength = 5; },
|
||||
value => { value.context.dukeVidal.leader.id = 'forged-disc'; },
|
||||
value => { value.context.dukeVidal.leader.capturedBy = 'other'; },
|
||||
value => { value.context.dukeVidal.leader.gholaBy = 'other'; },
|
||||
value => { value.context.dukeVidal.leader.concealed = {
|
||||
captor: 'other', controller: 'ec', dead: true, deaths: 1,
|
||||
}; },
|
||||
value => { value.context.dukeVidal.controller = 'absent'; },
|
||||
value => { value.context.dukeVidal.acquiredTurn = 2; },
|
||||
value => { value.player.leaders.push(structuredClone(value.context.dukeVidal.leader)); },
|
||||
value => { value.context.players[1].leaders.push(structuredClone(value.context.dukeVidal.leader)); },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const value = fixture(); change(value);
|
||||
const before = structuredClone(value.context);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(value.context, value.player, DUKE_VIDAL_ID), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices(value.context, value.player), []);
|
||||
assert.deepEqual(value.context, before);
|
||||
}
|
||||
const { context, player } = fixture();
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection({ ...context, dukeVidal: undefined }, player, DUKE_VIDAL_ID), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ ...context, dukeVidal: undefined }, player), []);
|
||||
});
|
||||
|
||||
void test('unsupported Advanced custody envelopes use the same no-option projection as missing source facts', () => {
|
||||
const { context, player } = fixture(true);
|
||||
const blocked = { ...context, players: [player,
|
||||
{ id: 'h', faction: 'harkonnen' as const, leaders: leaders('harkonnen') }] };
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(blocked, player, DUKE_VIDAL_ID), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices(blocked, player), []);
|
||||
const incomplete = { advanced: true, status: 'playing' as const, turn: 3 };
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection(incomplete, player, DUKE_VIDAL_ID), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices(incomplete, player), []);
|
||||
});
|
||||
|
||||
void test('minimal ordinary contexts retain their actual own first-death leader and reject repeated or foreign custody', () => {
|
||||
const own = leaders('ecaz')[0], repeated = leaders('ecaz')[1], foreign = leaders('emperor')[0];
|
||||
const captive = leaders('ecaz')[2], ghola = leaders('ecaz')[3];
|
||||
for (const leader of [own, repeated, foreign, captive, ghola]) { leader.dead = true; leader.deaths = 1; }
|
||||
repeated.deaths = 2; captive.capturedBy = 'other'; ghola.gholaBy = 'other';
|
||||
const player = { faction: 'ecaz' as const, leaders: [own, repeated, foreign, captive, ghola], battleLosses: 0 };
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: false }, player, own.id), own);
|
||||
for (const leader of [repeated, foreign, captive, ghola])
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: false }, player, leader.id), null);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: false }, player, 'unknown'), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ advanced: false }, player), [
|
||||
{ id: own.id, name: own.name, strength: own.strength },
|
||||
]);
|
||||
});
|
||||
|
||||
void test('minimal first-KH contexts keep their existing Advanced activation and revival-cycle boundaries', () => {
|
||||
const player = { faction: 'atreides' as const, leaders: [], battleLosses: 7,
|
||||
kwisatz: { dead: true, revivalCycle: 1 } };
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: true }, player, 'kwisatz'), 'kwisatz');
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ advanced: true }, player), [
|
||||
{ id: 'kwisatz', name: 'Kwisatz Haderach', strength: 2 },
|
||||
]);
|
||||
for (const unavailable of [
|
||||
{ ...player, battleLosses: 6 },
|
||||
{ ...player, kwisatz: { dead: false, revivalCycle: 1 } },
|
||||
{ ...player, kwisatz: { dead: true, revivalCycle: 2 } },
|
||||
]) {
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: true }, unavailable, 'kwisatz'), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ advanced: true }, unavailable), []);
|
||||
}
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: false }, player, 'kwisatz'), null);
|
||||
assert.deepEqual(tleilaxuAmbassadorLeaderChoices({ advanced: false }, player), []);
|
||||
assert.equal(tleilaxuAmbassadorLeaderSelection({ advanced: true }, {
|
||||
...player, kwisatz: { dead: true },
|
||||
}, 'kwisatz'), 'kwisatz', 'Legacy first-cycle KH records retain their default');
|
||||
});
|
||||
Reference in new issue
Block a user