Files
Dune/tests/fixture-kwisatz-ambassador.ts

181 lines
9.7 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeFactionExpansionsGameForAudit, joinGame,
newPlayer, type Action, type Game,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
export type KwisatzAmbassadorStep = { actor: string; action: Action };
export type KwisatzAmbassadorFixture = {
game: Game; beforeArrival: Game; afterDeath: Game; afterLosses: Game;
actions: KwisatzAmbassadorStep[]; owner: string; beneficiary: string;
entrant: string; token: string; copied: boolean; location: string;
};
/** Control only the original nine-draw Ambassador packet when starting forces
* initialize; no token identity, cohort or history is assigned afterwards. */
function withPacket<T>(copied: boolean, run: () => T): T {
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
const first: Exclude<AmbassadorEffect, 'ecaz'>[] = copied
? ['beneGesserit', 'atreides', 'choam', 'emperor', 'fremen']
: ['tleilaxu', 'atreides', 'choam', 'emperor', 'fremen'];
const desired = [...first, ...pool.filter(effect => !first.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]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length)
array[0] = rolls[cursor++];
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Two original battles record seven losses, then an actual KH Lasgun/Shield
* death. Original worm alliance, placement and entrant shipment invoke the grant.
* Later force/card positions are conserved; no clocks, wallets, deaths or allies
* are fabricated. Ghola returns only the accompanying ordinary leader. */
export function createKwisatzAmbassadorFixture(copied = false): KwisatzAmbassadorFixture {
let game = createGame('KWISATZAMBASSADOR', newPlayer('x', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('g', 'Guild', 'guild'));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = initializeFactionExpansionsGameForAudit(game);
const actions: KwisatzAmbassadorStep[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action }); game = applyAction(game, actor, action);
};
const clean = () => !game.decision && !game.response && !game.phaseOpening && !game.pendingTreacheryDiscard;
const next = () => {
if (game.decision?.kind === 'ecazPlacement')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
const step = nextRicheseSkillsTechNativeStep(game);
if (step.action.type === 'stormDial') step.action.amount = 0;
return step;
};
const reach = (until: () => boolean) => {
for (let n = 0; !until() && n < 2400; n++) {
assert.notEqual(game.status, 'finished'); const step = next(); act(step.actor, step.action);
}
assert.ok(until(), 'Original native actions must reach the KH Ambassador boundary');
};
while (game.status === 'setup') {
const step = next();
const after = withPacket(copied, () => applyAction(game, step.actor, step.action));
actions.push(step); game = after;
}
const effect = copied ? 'beneGesserit' : 'tleilaxu';
const token = game.ecazAmbassadors!.tokens.find(token => token.effect === effect)!;
assert.equal(token.zone, 'supply');
assert.equal(game.ecazAmbassadors!.tokens.find(t => t.effect === 'tleilaxu')!.zone, copied ? 'pool' : 'supply');
const landBlows = (count: number) => {
for (let at = 0; at < count; at++) {
const index = game.spiceDeck.findIndex((card, i) => i >= at && 'territory' in card);
assert.ok(index >= at); game.spiceDeck.splice(at, 0, game.spiceDeck.splice(index, 1)[0]);
}
};
landBlows(2);
const reserveBoard = () => {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
}
};
const stageBattle = (amount: number, enemy: number) => {
reserveBoard();
const site = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm)); assert.ok(site);
const key = `${site.id}:${site.sectors[0]}`;
for (const [id, count] of [['a', amount], ['g', enemy]] as const) {
const player = semutaEmperorPlayer(game, id); assert.ok(player.reserves >= count);
player.reserves -= count; player.forces[key] = count;
}
return site.id;
};
const openBattle = (site: string) => {
reach(() => game.phase === 6 && clean());
while (game.active === 'x') { const step = next(); act(step.actor, step.action); }
assert.ok(game.active === 'a' || game.active === 'g');
act(game.active!, { type: 'chooseBattle', territory: site, target: game.active === 'a' ? 'g' : 'a' });
while (game.response || game.decision || game.battle?.preparation || game.battle?.preLeader?.closed === false) {
const step = next(); act(step.actor, step.action);
}
};
const seal = (actor: string, action: Action) => {
act(actor, action);
while (game.response || game.decision?.kind === 'fullPlanOffer') {
const step = next(); act(step.actor, step.action);
}
};
const nativeLeader = (id: string, strongest: boolean) => {
const leaders = semutaEmperorPlayer(game, id).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
assert.ok(leaders[0]); return leaders[0].id;
};
reach(() => game.phase === 5 && clean());
const firstSite = stageBattle(7, 2); openBattle(firstSite);
seal('a', { type: 'battlePlan', leader: nativeLeader('a', false), dial: 0, support: 0 });
seal('g', { type: 'battlePlan', leader: nativeLeader('g', true), dial: 1, support: 1 });
reach(() => !game.battle && clean());
assert.equal(semutaEmperorPlayer(game, 'a').battleLosses, 7);
const afterLosses = structuredClone(game);
reach(() => game.turn === 2 && game.phase === 1 && clean());
const worm = game.spiceDeck.findIndex(card => 'worm' in card); assert.ok(worm >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
reach(() => game.nexus && !game.spiceWindow && !game.spiceResolution && clean());
act('x', { type: 'alliance', target: 'a' }); act('a', { type: 'alliance', target: 'x' });
reach(() => game.phase === 5 && clean());
const secondSite = stageBattle(3, 3);
const shield = moveSemutaEmperorCard(game, semutaEmperorCardId(game, card => card.kind === 'shield'), 'a');
const lasgun = moveSemutaEmperorCard(game, semutaEmperorCardId(game, card => card.kind === 'lasgun'), 'g');
openBattle(secondSite);
const accompanying = nativeLeader('a', false);
seal('a', { type: 'battlePlan', leader: accompanying, dial: 0, support: 0, defense: shield, kwisatz: true });
seal('g', { type: 'battlePlan', leader: nativeLeader('g', true), dial: 0, support: 0, weapon: lasgun });
reach(() => !game.battle && clean());
assert.equal(semutaEmperorPlayer(game, 'a').kwisatz?.dead, true);
assert.equal(semutaEmperorPlayer(game, 'a').kwisatz?.revivalCycle, 1);
const afterDeath = structuredClone(game);
landBlows(4);
reach(() => game.turn === 3 && game.phase === 4 && game.decision?.kind === 'ecazPlacement');
const destination = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm) &&
!game.players.some(p => Object.keys(p.forces).some(key => key.startsWith(`${t.id}:`))));
assert.ok(destination);
act('x', { type: 'decision', token: token.id, territory: destination.id });
while (game.response) { const step = next(); act(step.actor, step.action); }
assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token.id)!.zone, 'placed');
act('x', { type: 'decision', decline: true });
reach(() => game.phase === 5 && game.active === 'g' && clean());
const ghola = moveSemutaEmperorCard(game, semutaEmperorCardId(game, card => card.effect === 'ghola'), 'a');
act('a', { type: 'card', card: ghola, leader: accompanying });
reach(clean);
const atreides = semutaEmperorPlayer(game, 'a');
atreides.reserves += atreides.tanks; atreides.tanks = 0;
assert.equal(atreides.kwisatz?.dead, true);
assert.ok(atreides.leaders.every(l => !l.dead));
const beforeArrival = structuredClone(game);
act('g', { type: 'ship', amount: 1, territory: destination.id, sector: destination.sectors[0] });
reach(() => game.pendingAmbassador?.stage === 'offer');
const event = game.pendingAmbassador!.event;
act('x', { type: 'decision', event, trigger: true, beneficiary: 'a', choice: 'effect' });
if (copied) {
assert.equal(game.pendingAmbassador?.stage, 'copy');
act('a', { type: 'decision', event, effect: 'tleilaxu' });
}
assert.equal(game.pendingAmbassador?.stage, 'revival');
return { game, beforeArrival, afterDeath, afterLosses, actions, owner: 'x', beneficiary: 'a',
entrant: 'g', token: token.id, copied, location: destination.id };
}