Files
Dune/tests/fixture-nexus-emperor-payment.ts
T

214 lines
12 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 } from '../game/cards';
import { validateNexusCards } from '../game/nexus-cards';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type NexusEmperorPaymentStep = { actor: string; action: Action };
export type NexusEmperorPaymentTransition = { before: Game; step: NexusEmperorPaymentStep; after: Game };
export type NexusEmperorPaymentFixture = {
game: Game;
owner: string;
winner: string;
emperor: string;
neutral: string;
event: string;
price: number;
acquired: string;
karama: string;
originalCards: string[];
actions: NexusEmperorPaymentStep[];
staging: string[];
chronology: {
initial: Game;
afterSetup: Game;
firstMentat: NexusEmperorPaymentTransition;
worm: Game;
alliance: Game;
draw: NexusEmperorPaymentTransition;
afterClosingDraw: Game;
beforeFunding: Game;
beforeBid: Game;
};
wallets: { winner: number; emperorBeforeAid: number; emperor: number; matchingAid: number };
};
export function nexusEmperorPaymentPlayer(game: Game, id: string): Player {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing original Emperor-payment seat ${id}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard;
/** Fix only scalar native randomness. UUIDs and all other native entropy remain real. */
function scalarEntropy<T>(operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = 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');
}
}
export function stepNexusEmperorPayment(game: Game, step: NexusEmperorPaymentStep,
actions?: NexusEmperorPaymentStep[]): Game {
actions?.push(structuredClone(step));
return scalarEntropy(() => applyAction(game, step.actor, step.action));
}
export function nextNexusEmperorPaymentStep(game: Game): NexusEmperorPaymentStep {
if (clean(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 } };
}
if (!game.phaseOpening && !game.response && game.decision?.kind === 'nexusEmperorPayment')
return { actor: game.decision.player,
action: { type: 'nexusEmperorPayment', event: game.decision.event, use: false } };
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next, 'The genuine Emperor-payment chronology must not require a manufactured battle plan');
return next;
}
export function advanceNexusEmperorPayment(game: Game, until: (game: Game) => boolean,
actions?: NexusEmperorPaymentStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = stepNexusEmperorPayment(game, nextNexusEmperorPaymentStep(game), actions);
}
throw new Error('The original Emperor-payment chronology did not reach its requested boundary');
}
/** Physical ownership zones only: paid auction prefixes are references, not duplicates. */
export function nexusEmperorPaymentCards(game: Game): string[] {
return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])]
.map(card => card.id).sort();
}
export function nexusEmperorPaymentInventory(game: Game): void {
validateNexusCards(game.nexusCards!.cards!, game.players);
assert.deepEqual(nexusEmperorPaymentCards(game), treacheryDeck().map(card => card.id).sort());
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0), 5);
}
}
function frontOriginalSpice(game: Game, kinds: ('land' | 'worm')[]): void {
const prefix = kinds.map(kind => {
const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
});
game.spiceDeck.unshift(...prefix);
}
/** Genuine four-seat Basic/Advanced native history. Only still-undealt physical
* singleton order is controlled; no hand, wallet, army, phase, alliance or receipt
* is assigned. The original first turn ends before the later real worm/alliance. */
export function createNexusEmperorPaymentFixture(options: {
advanced?: boolean; prepaidAid?: number; neutralHolder?: boolean; truthtrance?: boolean;
} = {}): NexusEmperorPaymentFixture {
const owner = 'atreides', emperor = 'emperor', winner = 'guild', neutral = 'harkonnen';
const price = 2, matchingAid = options.prepaidAid ?? 0;
assert.ok(Number.isInteger(matchingAid) && matchingAid >= 0 && matchingAid <= price);
const actions: NexusEmperorPaymentStep[] = [], staging: string[] = [];
let game = createGame('NEXUSEMPERORPAYMENT', newPlayer(owner, 'Atreides', 'atreides'), options.advanced ?? false);
joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
joinGame(game, newPlayer(winner, 'Guild', 'guild'));
joinGame(game, newPlayer(neutral, 'Harkonnen', 'harkonnen'));
game.nexusCards = { cards: null, phase: null };
for (const player of game.players) game = stepNexusEmperorPayment(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = scalarEntropy(() => initializeNexusGameForAudit(game));
assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0));
// Both printed Karama cards are actually dealt by the original starting-card
// routine: one keeps Guild's ordinary payment choice, one can cancel income.
const prefix = [];
for (const kind of ['worthless', 'worthless', 'karama', 'karama', options.truthtrance ? 'truthtrance' : 'worthless']) {
const index = game.deck.findIndex(card => kind === 'worthless' ? card.kind === kind : card.effect === kind);
assert.ok(index >= 0);
prefix.push(game.deck.splice(index, 1)[0]);
}
game.deck.unshift(...prefix);
staging.push('Ordered five original undealt starting Treachery singletons before their native setup deal.');
const initial = structuredClone(game);
game = advanceNexusEmperorPayment(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
const karama = nexusEmperorPaymentPlayer(game, neutral).hand.find(card => card.effect === 'karama')!.id;
assert.ok(nexusEmperorPaymentPlayer(game, winner).hand.some(card => card.effect === 'karama'));
game = advanceNexusEmperorPayment(game, state => state.phase === 1 && clean(state), actions);
frontOriginalSpice(game, ['land', 'land']);
staging.push('Ordered original undealt non-worm first-turn Spice Cards; no first-turn Nexus manufactured.');
let firstMentat: NexusEmperorPaymentTransition | undefined;
while (game.turn === 1) {
const before = structuredClone(game), step = nextNexusEmperorPaymentStep(game);
game = stepNexusEmperorPayment(game, step, actions);
if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) };
}
assert.ok(firstMentat);
game = advanceNexusEmperorPayment(game, state => state.turn === 2 && state.phase === 1 && clean(state), actions);
frontOriginalSpice(game, ['worm', 'land', 'land']);
staging.push('Ordered original still-undealt turn-two worm and subsequent land singletons.');
game = advanceNexusEmperorPayment(game, state => !!state.nexus && !state.spiceWindow &&
!state.spiceResolution && clean(state), actions);
const worm = structuredClone(game);
for (const [actor, target] of [[emperor, winner], [winner, emperor]])
game = stepNexusEmperorPayment(game, { actor, action: { type: 'alliance', target } }, actions);
assert.equal(nexusEmperorPaymentPlayer(game, emperor).ally, winner);
assert.equal(nexusEmperorPaymentPlayer(game, winner).ally, emperor);
const alliance = structuredClone(game);
game = advanceNexusEmperorPayment(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), actions);
const cards = game.nexusCards!.cards!;
const index = cards.deck.indexOf('emperor'); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
staging.push('Ordered original undealt Emperor Nexus singleton immediately before its real closing draw.');
const beforeDraw = structuredClone(game);
const drawStep: NexusEmperorPaymentStep = { actor: owner, action: { type: 'nexusCardChoice',
turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
game = stepNexusEmperorPayment(game, drawStep, actions);
assert.equal(game.nexusCards!.cards!.hands[owner], 'emperor');
const draw = { before: beforeDraw, step: drawStep, after: structuredClone(game) };
if (options.neutralHolder ?? true) {
const irrelevant = game.nexusCards!.cards!.deck.indexOf('fremen'); assert.ok(irrelevant >= 0);
game.nexusCards!.cards!.deck.unshift(game.nexusCards!.cards!.deck.splice(irrelevant, 1)[0]);
staging.push('Ordered original undealt irrelevant Fremen Nexus singleton for the other unallied closing draw.');
game = stepNexusEmperorPayment(game, { actor: neutral, action: { type: 'nexusCardChoice',
turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, actions);
} else game = stepNexusEmperorPayment(game, { actor: neutral, action: { type: 'nexusCardChoice',
turn: game.turn, card: null, choice: 'keep', ownRedraws: 0 } }, actions);
const afterClosingDraw = structuredClone(game);
game = advanceNexusEmperorPayment(game, state => state.phase === 3 && !!state.auction && clean(state), actions);
assert.equal(game.turn, 2);
assert.ok(game.order.indexOf(neutral) < game.order.indexOf(owner), 'The real later storm order provides a neutral responder before Use');
const beforeFunding = structuredClone(game);
const emperorBeforeAid = nexusEmperorPaymentPlayer(game, emperor).spice;
if (matchingAid) game = stepNexusEmperorPayment(game, { actor: emperor,
action: { type: 'pledgeAid', amount: matchingAid } }, actions);
while (game.auction!.active !== winner)
game = stepNexusEmperorPayment(game, { actor: game.auction!.active, action: { type: 'passBid' } }, actions);
const beforeBid = structuredClone(game), acquired = game.auction!.cards[game.auction!.index].id;
game = stepNexusEmperorPayment(game, { actor: winner, action: { type: 'bid', amount: price, allyPayment: matchingAid } }, actions);
game = advanceNexusEmperorPayment(game, state => state.decision?.kind === 'nexusEmperorPayment', actions);
assert.equal(game.auction!.bid, price); assert.equal(game.auction!.bidder, winner);
assert.equal(game.auction!.cards[game.auction!.index].id, acquired);
assert.equal(nexusEmperorPaymentPlayer(game, winner).hand.some(card => card.id === acquired), false);
const decision = game.decision;
assert.ok(decision?.kind === 'nexusEmperorPayment');
nexusEmperorPaymentInventory(game);
return { game, owner, emperor, winner, neutral, event: decision.event, price, acquired, karama,
originalCards: nexusEmperorPaymentCards(game), actions, staging,
chronology: { initial, afterSetup, firstMentat, worm, alliance, draw, afterClosingDraw, beforeFunding, beforeBid },
wallets: { winner: nexusEmperorPaymentPlayer(game, winner).spice, emperorBeforeAid,
emperor: nexusEmperorPaymentPlayer(game, emperor).spice, matchingAid } };
}