Files
Dune/tests/fixture-ecaz-strongholds.ts

198 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, initializeDiscoveryGameForAudit, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer,
viewGame, type Game } from '../game/engine';
import { territory } from '../game/board';
import { createStrongholdCards, STRONGHOLD_CARDS, type StrongholdId } from '../game/stronghold-cards';
import { finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { createTechTokens } from '../game/tech-tokens';
export type EcazStrongholdOptions = {
/** Ready authenticated lobby, or its original fresh native setup. Seats are never replaced. */
initial?: Game;
kind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
ecazForces?: number;
allyForces?: number;
opponentForces?: number;
discoveries?: boolean;
tech?: boolean;
};
export type EcazStrongholdFixture = {
initial: Game; afterSetup: Game; beforeMentat: Game; mentatStep: StrongholdFactionsNativeStep;
afterMentat: Game; beforeBattle: Game; game: Game;
ecaz: string; ally: string; opponent: string; territory: EcazStrongholdOptions['kind'] & string;
location: string; ecazForces: number; allyForces: number; staging: string[];
};
export const ecazStrongholdSeat = (game: Game, actor: string) => {
const player = game.players.find(p => p.id === actor);
assert.ok(player, 'Every actor retains its originally admitted identity.');
return player;
};
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision;
export function nextEcazStronghold(game: Game): Game {
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next, 'A native pending plan requires the actual selected plan owner.');
return applyAction(game, next.actor, next.action);
}
export function advanceEcazStronghold(game: Game, predicate: (game: Game) => boolean): Game {
for (let attempt = 0; attempt < 1800; attempt++) {
if (predicate(game)) return game;
assert.equal(game.status, 'playing');
game = nextEcazStronghold(game);
}
throw Error('Original Ecaz/Stronghold chronology did not reach its boundary.');
}
function reserveBoard(game: Game): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
}
function place(game: Game, owner: string, location: string, count: number): void {
const player = ecazStrongholdSeat(game, owner);
assert.ok(Number.isSafeInteger(count) && count >= 0 && count <= player.reserves);
player.reserves -= count;
if (count) player.forces[location] = (player.forces[location] ?? 0) + count;
}
function orderBlow(game: Game, worm: boolean, position: number): void {
const at = game.spiceDeck.findIndex((card, i) => i >= position && (worm
? 'worm' in card && !card.greatMaker && !card.suppressed
: 'territory' in card));
assert.ok(at >= position, 'Only an actual unplayed Spice Card may be ordered.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
}
/** Determinism is limited to the original native shuffle, never a saved deal. */
function initialize(game: Game, discoveries: boolean): Game {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try {
if (discoveries) {
game.discoveryEnabled = true;
game.strongholdCards ??= createStrongholdCards();
return initializeDiscoveryGameForAudit(game);
}
return initializeStrongholdFactionsGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Genuine Advanced E3 setup and reciprocal Nexus alliance. Controlled physical
* board relocations follow setup; no bank grants, phase assignments, ownership
* assignments, replaced seats, manufactured plans or battle receipts. */
export function ecazStrongholdFixture(options: EcazStrongholdOptions = {}): EcazStrongholdFixture {
const kind = options.kind ?? 'arrakeen';
const location = `${kind}:${territory(kind).sectors[0]}`;
const ecazForces = options.ecazForces ?? 5, allyForces = options.allyForces ?? 4;
const staging: string[] = [];
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0);
assert.equal(game.advanced, true);
assert.deepEqual(game.expansions, ['ecaz']);
} else {
game = createGame('ECAZSTRONGHOLDS', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
// Original printed seats put the opposing Emperor first at the later storm.
for (const [actor, position] of [['ecaz', 6], ['guild', 4], ['emperor', 5]] as const)
if (viewGame(game, actor).playerPositions[actor] !== position)
game = applyAction(game, actor, { type: 'seatPosition', position });
}
const ecaz = game.players.find(p => p.faction === 'ecaz')?.id;
const ally = game.players.find(p => p.faction === 'guild')?.id;
const opponent = game.players.find(p => p.faction === 'emperor')?.id;
assert.ok(ecaz && ally && opponent && game.players.length === 3,
'The original native seats must be Ecaz, Guild and Emperor.');
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
if (options.tech && !game.techTokens) game.techTokens = createTechTokens();
game = initialize(game, options.discoveries === true);
}
const initial = structuredClone(game);
assert.equal(game.strongholdCards!.claimedTurn, 0);
assert.deepEqual(Object.keys(game.strongholdCards!.owners).sort(), STRONGHOLD_CARDS.map(c => c.id).sort());
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
game = finishEcazOccupySetup(game);
const afterSetup = structuredClone(game);
reserveBoard(game);
staging.push('After genuine setup: return every physical board counter to its own reserves; preserve native wallets, cards, leaders and identities.');
orderBlow(game, false, 0); orderBlow(game, false, 1);
staging.push('Order only remaining physical Spice Cards: no turn-one worm, then a turn-two worm followed by real blows.');
// Native first Storm dials keep both subsequent 1..6 Storm Cards away from
// this printed battle sector, without replacing a storm field or source.
const sector = territory(kind).sectors[0];
const distance = sector <= 11 ? sector + 1 : 0;
const dials = new Map(game.stormDialers.map((id, index) => [id, index === 0 ? Math.min(6, distance) : Math.max(0, distance - 6)]));
for (let attempt = 0; !(game.turn === 2 && game.phase === 0 && clean(game)) && attempt < 1800; attempt++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
if (next.action.type === 'stormDial') next.action.amount = dials.get(next.actor) ?? 0;
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.turn, 2);
orderBlow(game, true, 0); orderBlow(game, false, 1); orderBlow(game, false, 2);
game = advanceEcazStronghold(game, g => g.phase === 1 && !!g.nexus && clean(g) && !g.spiceWindow && !g.spiceResolution);
game = applyAction(game, ecaz, { type: 'alliance', target: ally });
game = applyAction(game, ally, { type: 'alliance', target: ecaz });
assert.equal(ecazStrongholdSeat(game, ecaz).ally, ally);
assert.equal(ecazStrongholdSeat(game, ally).ally, ecaz);
game = advanceEcazStronghold(game, g => g.phase === 8 && clean(g));
reserveBoard(game);
place(game, ecaz, location, 1); place(game, ally, location, 1);
staging.push(`Before real turn-two END Mentat: conserve one Ecaz and one reciprocal allied Guild counter in ${kind}; leave every other stronghold uncontrolled.`);
let beforeMentat: Game | undefined, mentatStep: StrongholdFactionsNativeStep | undefined;
for (let attempt = 0; game.turn === 2 && attempt < 200; attempt++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
const before = structuredClone(game);
game = applyAction(game, next.actor, next.action);
if (game.turn === 3) { beforeMentat = before; mentatStep = next; }
}
assert.ok(beforeMentat && mentatStep);
assert.equal(game.strongholdCards!.owners[kind], ecaz);
assert.equal(game.strongholdCards!.claimedTurn, 2);
const afterMentat = structuredClone(game);
orderBlow(game, false, 0); orderBlow(game, false, 1);
game = advanceEcazStronghold(game, g => g.phase === 5 && clean(g));
reserveBoard(game);
place(game, ecaz, location, ecazForces); place(game, ally, location, allyForces);
place(game, opponent, location, options.opponentForces ?? 8);
staging.push(`Before original movement completion: conserved ${ecazForces} Ecaz, ${allyForces} Guild and ${options.opponentForces ?? 8} Emperor counters at ${kind}; no shipment fee or bank income is fabricated.`);
const beforeCards = new Set([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)].map(c => c.id));
stageEcazOccupyCard(game, opponent, c => c.effect === 'karama');
staging.push('Conserved canonical Karama transfer from existing physical deck/hand custody to Emperor, exposing the actual Occupy counter window.');
assert.deepEqual(new Set([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)].map(c => c.id)), beforeCards);
game = advanceEcazStronghold(game, g => g.phase === 6 && clean(g));
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === kind);
assert.ok(choice, 'Actual combined armies must be admitted by original chooseBattle.');
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: kind,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
assert.equal(game.decision?.kind, 'ecazBattleLead');
return { initial, afterSetup, beforeMentat, mentatStep, afterMentat, beforeBattle, game,
ecaz, ally, opponent, territory: kind, location, ecazForces, allyForces, staging };
}
export function openEcazStronghold(fixture: EcazStrongholdFixture, lead: 'ecaz' | 'ally'): Game {
let game = fixture.game;
const decision = game.decision;
assert.ok(decision?.kind === 'ecazBattleLead');
game = applyAction(game, fixture.ecaz, { type: 'decision', event: decision.event,
lead: lead === 'ecaz' ? fixture.ecaz : fixture.ally });
return advanceEcazStronghold(game, g => !!g.battle && !g.battle.preparation &&
(!g.battle.preLeader || g.battle.preLeader.closed) && clean(g));
}
export function finishEcazStronghold(game: Game): Game {
return advanceEcazStronghold(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard);
}