Files
Dune/tests/fixture-jacurutu-mixed-dial.ts

282 lines
14 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction,
battles,
createGame,
initializeDiscoveryGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
type Player,
} from '../game/engine';
import {
DISCOVERY_CARD_PLACEMENTS,
JACURUTU_SIETCH,
type DiscoveryOpaqueTokenId,
} from '../game/discoveries';
import type { JacurutuDialOffer } from '../game/discovery-battle';
import type { Casualties } from '../game/combat';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type JacurutuMixedDialStep = { actor: string; action: Action };
export type JacurutuMixedDialOptions = {
/** The defeated Emperor's actual revealed plan. Dial 2 with support 1 leaves
* two legal physical readings of the same wheel; dial 1 with support 0
* leaves exactly one. */
dial?: number;
support?: number;
};
export type JacurutuMixedDialFixture = {
/** Authenticated fresh lobby before any deal. */
initial: Game;
/** The original physical Discovery setup. */
setup: Game;
/** The genuine battle in Jacurutu Sietch before its final traitor vote. */
game: Game;
actions: JacurutuMixedDialStep[];
staging: string[];
/** Actual battle winner (Atreides). */
attacker: string;
/** Actual battle loser (Emperor) holding one ordinary and one Sardaukar. */
defender: string;
observer: string;
territory: string;
/** The surrounding territory the defeated forces entered from. */
parent: string;
token: string;
/** The last original traitor vote that completes the reveal. */
finalVote: JacurutuMixedDialStep;
defenderPlan: Action;
attackerPlan: Action;
defenderLeader: string;
attackerLeader: string;
/** Physical counters the defeated army actually sent to the Tanks. */
defenderTanks: { normal: number; elite: number };
dial: number;
support: number;
};
/** Engine surface owned by the parent integration; asserted here as a contract. */
export type JacurutuBattleReceipt = JacurutuDialOffer & {
selectedDial?: Casualties | null;
};
export type JacurutuDialDecision = { kind: 'jacurutuDial'; player: string; event: string };
export function jacurutuBattleReceipt(game: Game): JacurutuBattleReceipt | null {
const battle = game.battle as (Game['battle'] & { jacurutuDial?: JacurutuBattleReceipt }) | null;
return battle?.jacurutuDial ? structuredClone(battle.jacurutuDial) : null;
}
export function jacurutuCurrentDecision(game: Game): JacurutuDialDecision | null {
const decision = game.decision as JacurutuDialDecision | null;
return decision?.kind === 'jacurutuDial' ? decision : null;
}
const EMPEROR = 'emperor';
const ATREIDES = 'atreides';
type JacurutuProgress = { token: DiscoveryOpaqueTokenId | null; emperorShipped: boolean; atreidesShipped: boolean };
function clean(game: Game) {
return !game.phaseOpening && !game.response && !game.decision;
}
/** Genuine native policy plus the four real actions this scenario needs. */
function nextJacurutuMixedDialStep(game: Game, progress: JacurutuProgress): JacurutuMixedDialStep {
if (game.decision?.kind === 'discoveryEntry') {
const owner = game.decision.player;
const offer = viewGame(game, owner).discoveryEntry!;
if (owner === EMPEROR && !offer.blocked)
return { actor: owner, action: { type: 'decision', event: game.decision.event, accept: true,
groups: offer.sources.map(({ source, normal, elite }) => ({ source, normal, elite })) } };
return { actor: owner, action: { type: 'decision', event: game.decision.event, accept: false } };
}
// Collection: the sole surrounding occupant inspects then reveals the token.
if (game.phase === 7 && clean(game) && progress.token) {
const discoveries = viewGame(game, EMPEROR).discoveries!;
if (discoveries.canInspect.includes(progress.token))
return { actor: EMPEROR, action: { type: 'discovery', token: progress.token, reveal: false } };
if (discoveries.canReveal.includes(progress.token))
return { actor: EMPEROR, action: { type: 'discovery', token: progress.token, reveal: true } };
}
if (game.phase === 5 && clean(game)) {
if (game.turn === 1 && game.active === EMPEROR && !progress.emperorShipped) {
progress.emperorShipped = true;
return { actor: EMPEROR, action: { type: 'ship', territory: DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'].territory,
sector: DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'].sector, amount: 2, elite: 1, allyPayment: 0 } };
}
if (game.turn === 2 && game.active === ATREIDES && !progress.atreidesShipped) {
progress.atreidesShipped = true;
return { actor: ATREIDES, action: { type: 'ship', territory: JACURUTU_SIETCH, sector: 0,
amount: 3, elite: 0, allyPayment: 0 } };
}
return { actor: game.active!, action: { type: 'endMovement' } };
}
if (game.phase === 6 && !game.battle && clean(game)) {
const pending = battles(game);
if (pending.length) {
const choice = pending[0];
assert.equal(choice.territory, JACURUTU_SIETCH, 'Only the revealed nested location may hold this battle.');
const chooser = choice.chooser ?? choice.attacker;
return { actor: chooser, action: { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === chooser ? choice.defender : choice.attacker } };
}
}
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next, `No genuine Jacurutu dial step at ${game.status}/${game.turn}/${game.phase}.`);
return next;
}
function advance(
state: Game,
until: (game: Game) => boolean,
progress: JacurutuProgress,
actions: JacurutuMixedDialStep[],
): Game {
let game = state;
for (let step = 0; step < 1500 && !until(game); step++) {
assert.notEqual(game.status, 'finished');
const next = nextJacurutuMixedDialStep(game, progress);
actions.push(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(until(game), 'The genuine Jacurutu dial policy did not reach its requested window.');
return game;
}
/** Control only the actual placement lottery of the real Discovery blow. */
function withPlacement<T>(game: Game, operation: () => T): T {
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === 'hiereg');
const selected = eligible.findIndex(token => token.face === JACURUTU_SIETCH);
assert.ok(selected >= 0, 'The original Hiereg supply must still hold Jacurutu Sietch.');
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = Math.floor((selected + 0.5) / eligible.length * 0x100000000);
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function leaderOf(game: Game, id: string, strongest: boolean): Player['leaders'][number] {
const player = game.players.find(seat => seat.id === id)!;
const leader = player.leaders.filter(candidate => !candidate.dead && candidate.usedAt === undefined)
.sort((left, right) => strongest ? right.strength - left.strength : left.strength - right.strength)[0];
assert.ok(leader, `Faction ${player.faction} must still hold an unused leader.`);
return leader;
}
function applyStep(game: Game, step: JacurutuMixedDialStep, actions: JacurutuMixedDialStep[]): Game {
actions.push(step);
return applyAction(game, step.actor, step.action);
}
/**
* Genuine Advanced Discovery runtime: original setup, first real Storm, the
* actual Hiereg Discovery blow placing Jacurutu Sietch at its printed
* destination, a real Emperor shipment beside it, the real private inspect and
* reveal, the real next-turn free entry, a real Atreides shipment into the
* revealed location and the real declared battle. The only staging is the
* order of four original unplayed Spice Cards and the placement lottery; no
* card, wallet, force, phase, hand or receipt is written directly.
*/
export function createJacurutuMixedDialFixture(options: JacurutuMixedDialOptions = {}): JacurutuMixedDialFixture {
const dial = options.dial ?? 2;
const support = options.support ?? 1;
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
assert.equal(placement.type, 'hiereg');
let game = createGame('JACURUTUDIAL', newPlayer(EMPEROR, 'Emperor', 'emperor'), true, []);
joinGame(game, newPlayer(ATREIDES, 'Atreides', 'atreides'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
assert.deepEqual(game.expansions, []);
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game.discoveryEnabled = true;
const initial = structuredClone(game);
game = initializeDiscoveryGameForAudit(game);
const setup = structuredClone(game);
const actions: JacurutuMixedDialStep[] = [];
const staging: string[] = [];
// 1. The original setup and the first real Storm.
game = advance(game, state => state.status === 'playing', { token: null, emperorShipped: false, atreidesShipped: false }, actions);
const discovery = game.spiceDeck.findIndex(card => 'discovery' in card && card.discovery === 'discovery-hagga-basin');
assert.ok(discovery >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(discovery, 1)[0]);
for (let position = 1; position < 4; position++) {
const ordinary = game.spiceDeck.findIndex((card, index) => index >= position && card !== undefined &&
'territory' in card && !card.discovery && card.territory !== placement.territory);
assert.ok(ordinary >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
}
staging.push('Reordered four original unplayed Spice Cards so the real first blow is the Hiereg Discovery card; selected only the actual placement lottery.');
const progress: JacurutuProgress = { token: null, emperorShipped: false, atreidesShipped: false };
game = advance(game, state => state.phase === 1 && clean(state), progress, actions);
game = withPlacement(game, () => advance(game,
state => state.discoveries!.tokens.some(token => token.face === JACURUTU_SIETCH && token.status === 'placed'),
progress, actions));
const token = game.discoveries!.tokens.find(candidate => candidate.face === JACURUTU_SIETCH)!;
assert.equal(token.status, 'placed');
assert.equal(token.territory, placement.territory);
assert.equal(token.sector, placement.sector);
assert.notEqual(token.sector, game.storm);
progress.token = token.id;
// 2. The real Emperor shipment in his own first Movement turn beside the
// face-down token, then the real private inspect and reveal in that turn's
// Collection. The storm order never decides this sequence.
game = advance(game, state => state.phase === 5 && clean(state) && state.active === EMPEROR, progress, actions);
game = advance(game, state => state.discoveries!.tokens.find(candidate => candidate.id === token.id)!.revealedTurn === 1, progress, actions);
// 3. The real next-turn free entry into the revealed location.
game = advance(game, state => state.decision?.kind === 'discoveryEntry' && state.decision.player === EMPEROR, progress, actions);
game = applyStep(game, nextJacurutuMixedDialStep(game, progress), actions);
const entered = game.players.find(player => player.id === EMPEROR)!;
const enteredKey = `${JACURUTU_SIETCH}:0`;
assert.equal(entered.forces[enteredKey], 2, 'The real free entry must move both undialed counters.');
assert.equal(entered.elites!.forces[enteredKey], 1);
// 4. The real Atreides shipment into the revealed location and the battle.
game = advance(game, state => state.phase === 6 && !!state.battle && clean(state) && !state.battle.preparation, progress, actions);
const defenderLeader = leaderOf(game, EMPEROR, false);
const attackerLeader = leaderOf(game, ATREIDES, true);
assert.ok(attackerLeader.strength >= defenderLeader.strength + 3, 'The real strength comparison must leave no tie.');
const defenderPlan: Action = { type: 'battlePlan', leader: defenderLeader.id, dial, support };
const attackerPlan: Action = { type: 'battlePlan', leader: attackerLeader.id, dial: 2, support: 2 };
for (const [actor, plan] of [[EMPEROR, defenderPlan], [ATREIDES, attackerPlan]] as const) {
for (let step = 0; step < 20 && (!clean(game) || game.battle?.preparation); step++)
game = applyStep(game, nextJacurutuMixedDialStep(game, progress), actions);
game = applyStep(game, { actor, action: plan }, actions);
}
const defender = game.players.find(player => player.id === EMPEROR)!;
assert.equal(game.battle!.plans[EMPEROR]!.dial, dial);
assert.equal(game.battle!.plans[EMPEROR]!.support, support);
assert.equal(game.battle!.plans[ATREIDES]!.dial, 2);
assert.equal(defender.forces[enteredKey], 2);
assert.equal(defender.elites!.forces[enteredKey], 1);
// Hold back the final original traitor vote so the caller completes the reveal.
for (let step = 0; step < 20; step++) {
const voters = viewGame(game, game.host).battle!.traitorVoters
.filter(id => game.battle!.traitorCalls[id] === undefined);
if (voters.length <= 1) break;
game = applyStep(game, { actor: voters[0], action: { type: 'traitorCall', call: false } }, actions);
}
const voters = viewGame(game, game.host).battle!.traitorVoters
.filter(id => game.battle!.traitorCalls[id] === undefined);
assert.equal(voters.length, 1, 'One original traitor vote must still be pending.');
const finalVote: JacurutuMixedDialStep = { actor: voters[0], action: { type: 'traitorCall', call: false } };
return {
initial, setup, game, actions, staging,
attacker: ATREIDES, defender: EMPEROR, observer: 'guild',
territory: JACURUTU_SIETCH, parent: `${placement.territory}:${placement.sector}`, token: token.id,
finalVote, defenderPlan, attackerPlan,
defenderLeader: defenderLeader.id, attackerLeader: attackerLeader.id,
defenderTanks: { normal: 1, elite: 1 }, dial, support,
};
}