220 lines
13 KiB
TypeScript
220 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
|
|
type Action, type Game, type Player } from '../game/engine';
|
|
import { treacheryDeck, type Card } from '../game/cards';
|
|
import { AMBASSADOR_EFFECTS, validateAmbassadors } from '../game/ecaz-ambassadors';
|
|
import { validateNexusCards } from '../game/nexus-cards';
|
|
import { nextE3NexusEcazStep } from './fixture-e3-nexus-ecaz';
|
|
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
|
|
|
export type NexusEmperorAmbassadorStep = { actor: string; action: Action };
|
|
export type NexusEmperorAmbassadorTransition = {
|
|
before: Game; step: NexusEmperorAmbassadorStep; after: Game;
|
|
};
|
|
export type NexusEmperorAmbassadorFixture = {
|
|
game: Game; owner: string; buyer: string; entrant: string; event: string; token: string;
|
|
card: Card; actions: NexusEmperorAmbassadorStep[];
|
|
wallets: Record<string, number>;
|
|
chronology: {
|
|
initial: Game; undealt: Game; afterSetup: Game; firstMentat: NexusEmperorAmbassadorTransition;
|
|
worm: Game; alliance: Game; draw: NexusEmperorAmbassadorTransition; afterClosingDraw: Game;
|
|
placement: NexusEmperorAmbassadorTransition; beforeArrival: Game;
|
|
arrival: NexusEmperorAmbassadorTransition; entry: Game; trigger: NexusEmperorAmbassadorTransition;
|
|
};
|
|
};
|
|
export const nexusEmperorAmbassadorClean = (g: Game): boolean =>
|
|
!g.response && !g.phaseOpening && !g.decision && !g.truthtrance && !g.pendingTreacheryDiscard;
|
|
export function nexusEmperorAmbassadorPlayer(g: Game, id: string): Player {
|
|
const player = g.players.find(p => p.id === id); assert.ok(player); return player;
|
|
}
|
|
export function stepNexusEmperorAmbassador(g: Game, step: NexusEmperorAmbassadorStep,
|
|
actions?: NexusEmperorAmbassadorStep[]): Game {
|
|
actions?.push(structuredClone(step));
|
|
return withClassicDiscoveryNexusLottery(() => applyAction(structuredClone(g), step.actor, structuredClone(step.action)));
|
|
}
|
|
export function nextNexusEmperorAmbassadorStep(g: Game): NexusEmperorAmbassadorStep {
|
|
if (!g.response && !g.phaseOpening && g.decision?.kind === 'nexusEmperorAmbassadorPurchase')
|
|
return { actor: g.decision.player,
|
|
action: { type: 'nexusEmperorAmbassadorPurchase', event: g.decision.event, use: false } };
|
|
const step = nextE3NexusEcazStep(g);
|
|
assert.ok(step, `Original Ambassador chronology needs its owner at ${g.turn}/${g.phase}/${g.decision?.kind}`);
|
|
return step;
|
|
}
|
|
export function advanceNexusEmperorAmbassador(g: Game, until: (g: Game) => boolean,
|
|
actions?: NexusEmperorAmbassadorStep[]): Game {
|
|
for (let n = 0; n < 2600; n++) {
|
|
if (until(g)) return g;
|
|
assert.notEqual(g.status, 'finished');
|
|
g = stepNexusEmperorAmbassador(g, nextNexusEmperorAmbassadorStep(g), actions);
|
|
}
|
|
throw Error('Original Ambassador chronology did not reach its requested boundary');
|
|
}
|
|
function transition(g: Game, step: NexusEmperorAmbassadorStep,
|
|
actions: NexusEmperorAmbassadorStep[]): NexusEmperorAmbassadorTransition {
|
|
return { before: structuredClone(g), step: structuredClone(step),
|
|
after: stepNexusEmperorAmbassador(g, step, actions) };
|
|
}
|
|
/** Original component shuffle only, while the five Ambassador tokens are undealt. */
|
|
function originalRicheseCohort<T>(operation: () => T): T {
|
|
const source = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
|
const desired = ['richese', ...source.filter(effect => effect !== 'richese')];
|
|
const current: string[] = [...source], 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]];
|
|
}
|
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
|
const native = 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] = rolls[cursor++] ?? 0xffffffff;
|
|
else Reflect.apply(native, crypto, [array]);
|
|
return array;
|
|
};
|
|
try { return operation(); }
|
|
finally {
|
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
|
}
|
|
}
|
|
/** Reorder only physical cards that have not yet left the original Spice deck. */
|
|
function originalBlow(g: Game, worm: boolean, land: string): void {
|
|
const desired = [...(worm ? ['worm'] : []), land, ...(g.advanced ? ['other'] : [])];
|
|
for (const [position, face] of desired.entries()) {
|
|
const index = g.spiceDeck.findIndex((c, i) => i >= position && (face === 'worm'
|
|
? 'worm' in c && !c.greatMaker && !c.suppressed && !c.thumper
|
|
: 'territory' in c && !c.discovery && (face === 'other'
|
|
? !['hagga_basin', 'red_chasm', 'rock_outcroppings'].includes(c.territory)
|
|
: c.territory === face)));
|
|
assert.ok(index >= position, `Original undealt Spice card ${face} must remain available`);
|
|
g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
|
|
}
|
|
}
|
|
export function nexusEmperorAmbassadorCards(g: Game): string[] {
|
|
return [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
|
|
...(g.auction?.cards.slice(g.auction.index + Number(!!g.currentAuctionSale)) ?? [])]
|
|
.map(c => c.id).sort();
|
|
}
|
|
export function nexusEmperorAmbassadorInventory(g: Game): void {
|
|
validateNexusCards(g.nexusCards!.cards!, g.players); validateAmbassadors(g.ecazAmbassadors!);
|
|
assert.deepEqual(nexusEmperorAmbassadorCards(g), treacheryDeck(['ecaz']).map(c => c.id).sort());
|
|
for (const p of g.players) assert.equal(p.reserves + p.tanks +
|
|
Object.values(p.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
|
}
|
|
/** Original standalone Ecaz Nexus setup, actual other-seat pact and closing draw,
|
|
* original paid placement, physical entry and committed Richese trigger. Only
|
|
* undealt deck/token lotteries are selected. No played hand, wallet, alliance,
|
|
* force, clock, receipt or control frame is installed by this producer. */
|
|
export function createNexusEmperorAmbassadorFixture(options: {
|
|
advanced?: boolean; nexusCard?: 'emperor' | 'guild'; truthtrance?: boolean;
|
|
arrival?: 'shipment' | 'worm'; buyer?: 'ecaz' | 'harkonnen';
|
|
} = {}): NexusEmperorAmbassadorFixture {
|
|
const advanced = options.advanced ?? true, owner = 'ecaz', buyer = options.buyer ?? owner;
|
|
assert.ok(buyer !== 'harkonnen' || options.arrival !== 'worm');
|
|
const actions: NexusEmperorAmbassadorStep[] = [];
|
|
let g = createGame('NEXUSEMPERORAMBASSADOR', newPlayer(owner, 'Ecaz', 'ecaz'), advanced, ['ecaz']);
|
|
for (const faction of ['guild', 'fremen', ...(buyer === 'harkonnen' ? ['harkonnen' as const] : [])] as const)
|
|
joinGame(g, newPlayer(faction, faction, faction));
|
|
g.nexusCards = { cards: null, phase: null };
|
|
const initial = structuredClone(g);
|
|
for (const player of g.players)
|
|
g = stepNexusEmperorAmbassador(g, { actor: player.id, action: { type: 'ready' } }, actions);
|
|
g = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(g));
|
|
assert.ok(g.players.every(p => !p.hand.length));
|
|
const front: Card[] = [];
|
|
for (const player of g.players) for (let n = 0; n < (player.faction === 'harkonnen' ? 2 : 1); n++) {
|
|
const index = g.deck.findIndex(card => options.truthtrance && player.id === 'guild'
|
|
? card.effect === 'truthtrance' : card.kind === 'worthless' || card.kind === 'shield');
|
|
assert.ok(index >= 0); front.push(g.deck.splice(index, 1)[0]);
|
|
}
|
|
g.deck.unshift(...front);
|
|
const undealt = structuredClone(g);
|
|
while (g.status === 'setup') {
|
|
const step = nextNexusEmperorAmbassadorStep(g);
|
|
if (step.action.type === 'fremenSetup')
|
|
step.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
|
|
const lastTraitor = step.action.type === 'traitor' && g.players.filter(p => p.traitorChoices.length).length === 1;
|
|
if (lastTraitor) {
|
|
actions.push(structuredClone(step));
|
|
g = originalRicheseCohort(() => applyAction(structuredClone(g), step.actor, step.action));
|
|
} else g = stepNexusEmperorAmbassador(g, step, actions);
|
|
}
|
|
const afterSetup = structuredClone(g);
|
|
assert.ok(g.ecazAmbassadors!.tokens.some(t => t.effect === 'richese' && t.zone === 'supply'));
|
|
originalBlow(g, false, 'hagga_basin');
|
|
let firstMentat: NexusEmperorAmbassadorTransition | undefined;
|
|
while (g.turn === 1) {
|
|
const t = transition(g, nextNexusEmperorAmbassadorStep(g), actions); g = t.after;
|
|
if (g.turn === 2) firstMentat = t;
|
|
}
|
|
assert.ok(firstMentat);
|
|
g = advanceNexusEmperorAmbassador(g, s => s.turn === 2 && s.phase === 1 && nexusEmperorAmbassadorClean(s), actions);
|
|
originalBlow(g, true, 'rock_outcroppings');
|
|
g = advanceNexusEmperorAmbassador(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && nexusEmperorAmbassadorClean(s), actions);
|
|
const worm = structuredClone(g);
|
|
for (const [actor, target] of [['guild', 'fremen'], ['fremen', 'guild']])
|
|
g = stepNexusEmperorAmbassador(g, { actor, action: { type: 'alliance', target } }, actions);
|
|
const alliance = structuredClone(g);
|
|
g = advanceNexusEmperorAmbassador(g, s => s.nexusCards?.phase?.stage === 'drawing' && nexusEmperorAmbassadorClean(s), actions);
|
|
const cards = g.nexusCards!.cards!, at = cards.deck.indexOf(options.nexusCard ?? 'emperor');
|
|
assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
|
|
const draw = transition(g, { actor: owner, action: { type: 'nexusCardChoice', turn: g.turn,
|
|
card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } }, actions); g = draw.after;
|
|
g = advanceNexusEmperorAmbassador(g, s => s.nexusCards?.phase?.stage !== 'drawing' && nexusEmperorAmbassadorClean(s), actions);
|
|
const afterClosingDraw = structuredClone(g);
|
|
g = advanceNexusEmperorAmbassador(g, s => s.decision?.kind === 'ecazPlacement' && !s.response && !s.phaseOpening, actions);
|
|
const token = g.ecazAmbassadors!.tokens.find(t => t.effect === 'richese')!.id;
|
|
const placement = transition(g, { actor: owner, action: { type: 'decision', token, territory: 'arrakeen' } }, actions);
|
|
g = advanceNexusEmperorAmbassador(placement.after, s => s.phase === 5 && nexusEmperorAmbassadorClean(s), actions);
|
|
assert.equal(g.ecazAmbassadors!.tokens.find(t => t.id === token)!.location, 'arrakeen');
|
|
if (options.arrival === 'worm') {
|
|
g = advanceNexusEmperorAmbassador(g, s => s.turn === 2 && s.phase === 5 &&
|
|
s.active === 'fremen' && nexusEmperorAmbassadorClean(s), actions);
|
|
g = stepNexusEmperorAmbassador(g, { actor: 'fremen',
|
|
action: { type: 'ship', territory: 'rock_outcroppings', sector: 13, amount: 1 } }, actions);
|
|
g = stepNexusEmperorAmbassador(g, { actor: 'fremen', action: { type: 'endMovement' } }, actions);
|
|
}
|
|
if (options.arrival === 'worm' || buyer === 'harkonnen') {
|
|
g = advanceNexusEmperorAmbassador(g, s => s.turn === 3 && s.phase === 1 && nexusEmperorAmbassadorClean(s), actions);
|
|
originalBlow(g, true, 'red_chasm');
|
|
}
|
|
if (buyer === 'harkonnen') {
|
|
g = advanceNexusEmperorAmbassador(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && nexusEmperorAmbassadorClean(s), actions);
|
|
for (const [actor, target] of [[owner, buyer], [buyer, owner]])
|
|
g = stepNexusEmperorAmbassador(g, { actor, action: { type: 'alliance', target } }, actions);
|
|
}
|
|
const entrant = options.arrival === 'worm' ? 'fremen' : 'guild';
|
|
let arrivalStep: NexusEmperorAmbassadorStep;
|
|
if (options.arrival === 'worm') {
|
|
g = advanceNexusEmperorAmbassador(g, s => s.decision?.kind === 'wormRide' && !s.response && !s.phaseOpening, actions);
|
|
assert.ok(g.decision?.kind === 'wormRide');
|
|
assert.equal(g.decision!.territory, 'rock_outcroppings');
|
|
const source = Object.keys(nexusEmperorAmbassadorPlayer(g, entrant).forces).find(key => key.startsWith('rock_outcroppings:'));
|
|
assert.ok(source);
|
|
arrivalStep = { actor: entrant, action: { type: 'decision', accept: true,
|
|
territory: 'arrakeen', sector: 10, forces: { [source]: 1 } } };
|
|
} else {
|
|
g = advanceNexusEmperorAmbassador(g, s => s.phase === 5 && s.active === entrant && nexusEmperorAmbassadorClean(s), actions);
|
|
arrivalStep = { actor: entrant, action: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 } };
|
|
}
|
|
const beforeArrival = structuredClone(g), arrival = transition(g, arrivalStep, actions); g = arrival.after;
|
|
g = advanceNexusEmperorAmbassador(g, s => !!s.pendingAmbassador && !s.response && !s.phaseOpening, actions);
|
|
const entry = structuredClone(g);
|
|
assert.equal(g.pendingAmbassador!.stage, 'offer');
|
|
const trigger = transition(g, { actor: owner, action: { type: 'decision',
|
|
event: g.pendingAmbassador!.event, trigger: true, beneficiary: buyer } }, actions); g = trigger.after;
|
|
assert.ok(g.decision?.kind === 'nexusEmperorAmbassadorPurchase');
|
|
assert.equal(g.decision!.player, buyer);
|
|
const event = g.decision!.event, card = g.deck[0]; assert.ok(card);
|
|
nexusEmperorAmbassadorInventory(g);
|
|
return { game: g, owner, buyer, entrant, event, token, card, actions,
|
|
wallets: Object.fromEntries(g.players.map(p => [p.id, p.spice])),
|
|
chronology: { initial, undealt, afterSetup, firstMentat, worm, alliance, draw,
|
|
afterClosingDraw, placement, beforeArrival, arrival, entry, trigger } };
|
|
}
|
|
/** Deterministic source successor: finish only the original purchase's response
|
|
* children. Its actual movement/worm suffix is left for the caller to observe. */
|
|
export function finishNexusEmperorAmbassador(g: Game): Game {
|
|
return advanceNexusEmperorAmbassador(g, s => !s.pendingAmbassador && !s.response && !s.truthtrance);
|
|
}
|