210 lines
13 KiB
TypeScript
210 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { traitorDeck } from '../game/traitors';
|
|
import { homeworldContext } from '../game/homeworld-game';
|
|
import { homeworldForceGroups } from '../game/homeworld-custody';
|
|
import { nativeShipmentSources } from '../game/homeworld-options';
|
|
import { caladanReinforcementChoice } from '../game/caladan-reinforcement-options';
|
|
import { actAdvisorFaceDanceFlip, advisorFaceDanceFlipClean, advisorFaceDanceFlipInventory,
|
|
advisorFaceDanceFlipTraitors, nextAdvisorFaceDanceFlip } from './fixture-advisor-face-dance-flip';
|
|
|
|
export type CaladanFaceDanceStep = { actor: string; action: Action };
|
|
export const actCaladanFaceDance = actAdvisorFaceDanceFlip;
|
|
export const caladanFaceDanceClean = advisorFaceDanceFlipClean;
|
|
export function caladanFaceDancePlayer(game: Game, id: string) {
|
|
const player = game.players.find(player => player.id === id);
|
|
assert.ok(player, `The original ${id} seat must remain present`);
|
|
return player;
|
|
}
|
|
export function caladanFaceDanceInventory(game: Game) {
|
|
return { treachery: advisorFaceDanceFlipInventory(game), traitors: advisorFaceDanceFlipTraitors(game) };
|
|
}
|
|
export function caladanFaceDancePopulation(game: Game) {
|
|
assert.ok(game.homeworlds?.custody);
|
|
const caladan = homeworldForceGroups(homeworldContext(game), game.homeworlds.custody)
|
|
.find(world => world.id === 'homeworld:atreides');
|
|
assert.ok(caladan);
|
|
const pool = caladan.forces.a;
|
|
assert.ok(pool);
|
|
return pool.normal + pool.elite;
|
|
}
|
|
export function caladanFaceDancePhysical(game: Game) {
|
|
return { population: caladanFaceDancePopulation(game), custody: structuredClone(game.homeworlds!.custody),
|
|
players: game.players.map(player => ({ id: player.id, spice: player.spice, reserves: player.reserves,
|
|
tanks: player.tanks, forces: structuredClone(player.forces), elites: structuredClone(player.elites),
|
|
shipped: player.shipped, moved: player.moved })) };
|
|
}
|
|
export function nextCaladanFaceDanceStep(game: Game): CaladanFaceDanceStep | null {
|
|
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
|
if (game.decision?.kind === 'caladanReinforcement' || game.decision?.kind === 'faceDance') return null;
|
|
if (game.status === 'setup' && game.setupStage === 'traitors') {
|
|
const player = game.players.find(player => player.traitorChoices.length);
|
|
assert.ok(player);
|
|
const leader = player.traitorChoices.find(identity => identity !== 'atreides-0');
|
|
assert.ok(leader, 'A real alternative initial Traitor choice must preserve the original winning Face Dancer');
|
|
return { actor: player.id, action: { type: 'traitor', leader } };
|
|
}
|
|
}
|
|
return nextAdvisorFaceDanceFlip(game);
|
|
}
|
|
export function advanceCaladanFaceDance(game: Game, until: (state: Game) => boolean,
|
|
actions: CaladanFaceDanceStep[] = []): Game {
|
|
for (let count = 0; count < 1400; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished', 'The native Caladan/Face Dance source ended before its owned boundary');
|
|
const step = nextCaladanFaceDanceStep(game);
|
|
assert.ok(step, `Choose the real ${game.turn}/${game.phase}/${game.decision?.kind}/${game.battle?.preparation?.kind}/${game.battle?.preLeader?.closed} before continuing`);
|
|
game = actCaladanFaceDance(game, step.actor, step.action, actions);
|
|
}
|
|
throw Error('The native Caladan/Face Dance source did not reach its finite boundary');
|
|
}
|
|
|
|
/** Select only the first native Face Dancer shuffle inside the actual final
|
|
* Traitor choice. The winning identity is never installed in a played hand. */
|
|
function originalFaceDancerDeal(game: Game, step: CaladanFaceDanceStep, actions: CaladanFaceDanceStep[]) {
|
|
assert.equal(game.setupStage, 'traitors');
|
|
assert.equal(step.action.type, 'traitor');
|
|
assert.equal(game.players.filter(player => player.traitorChoices.length > 0).length, 1);
|
|
assert.ok(game.players.every(player => !player.faceDancers));
|
|
const held = new Set([...game.players.flatMap(player => player.traitors), String(step.action.leader)]);
|
|
const original = traitorDeck(game.players, true).filter(identity => !held.has(identity));
|
|
assert.ok(original.includes('atreides-0'));
|
|
const first = ['atreides-0', ...original.filter(identity => identity !== 'atreides-0').slice(0, 2)];
|
|
const order = [...first, ...original.filter(identity => !first.includes(identity))];
|
|
const current = [...original], scalars: number[] = [];
|
|
for (let index = current.length - 1; index > 0; index--) {
|
|
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
|
|
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
|
|
[current[index], current[from]] = [current[from], current[index]];
|
|
}
|
|
assert.deepEqual(current, order);
|
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
|
|
let cursor = 0;
|
|
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
|
if (array instanceof Uint32Array && array.length === 1) {
|
|
assert.ok(cursor < scalars.length, 'Only the actual initial Face Dancer permutation uses these scalar choices');
|
|
array[0] = scalars[cursor++];
|
|
} else Reflect.apply(native, crypto, [array]);
|
|
return array;
|
|
};
|
|
try {
|
|
const next = applyAction(game, step.actor, step.action);
|
|
assert.equal(cursor, scalars.length);
|
|
actions.push(structuredClone(step));
|
|
return { game: next, original, order, scalars, step: structuredClone(step) };
|
|
} finally {
|
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
|
}
|
|
}
|
|
|
|
/** Public native A/T/G with original Ix deck and Homeworlds. Real Caladan4
|
|
* shipment and Guild1 invasion lead to the original ten-survivor victory.
|
|
* Only unopened deal/Spice positions and original scalar lotteries are chosen. */
|
|
export function createCaladanFaceDanceFixture(options: { advanced?: boolean } = {}) {
|
|
const advanced = options.advanced ?? false;
|
|
let game = createGame('CALADANFACEDANCE', newPlayer('a', 'Atreides', 'atreides'), advanced, ['ix']);
|
|
joinGame(game, newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
|
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
|
const actions: CaladanFaceDanceStep[] = [];
|
|
const act = (actor: string, action: Action) => { game = actCaladanFaceDance(game, actor, action, actions); };
|
|
const reach = (until: (state: Game) => boolean) => { game = advanceCaladanFaceDance(game, until, actions); };
|
|
act(game.host, { type: 'homeworlds', enabled: true });
|
|
for (const player of game.players) act(player.id, { type: 'ready' });
|
|
const initial = structuredClone(game);
|
|
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
|
const setup = structuredClone(game), originalInventory = caladanFaceDanceInventory(game);
|
|
let faceDancerDeal: ReturnType<typeof originalFaceDancerDeal> | null = null;
|
|
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
|
const step = nextCaladanFaceDanceStep(game);
|
|
assert.ok(step);
|
|
if (game.setupStage === 'traitors' && step.action.type === 'traitor' &&
|
|
game.players.filter(player => player.traitorChoices.length > 0).length === 1) {
|
|
faceDancerDeal = originalFaceDancerDeal(game, step, actions);
|
|
game = faceDancerDeal.game;
|
|
} else act(step.actor, step.action);
|
|
}
|
|
assert.equal(game.status, 'playing'); assert.ok(faceDancerDeal);
|
|
const afterSetup = structuredClone(game);
|
|
assert.equal(caladanFaceDancePopulation(game), 10);
|
|
assert.equal(caladanFaceDancePlayer(game, 'a').forces['arrakeen:10'], 10);
|
|
assert.ok(caladanFaceDancePlayer(game, 't').faceDancers!.some(card => card.leader === 'atreides-0' && !card.revealed));
|
|
assert.deepEqual(caladanFaceDanceInventory(game), originalInventory);
|
|
const front: Game['spiceDeck'] = [];
|
|
for (let count = 0; count < (advanced ? 2 : 1); count++) {
|
|
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
|
|
assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
|
|
}
|
|
game.spiceDeck.unshift(...front);
|
|
const shipments: { before: Game; step: CaladanFaceDanceStep; after: Game }[] = [];
|
|
const ship = (actor: string, territory: string, sector: number, amount: number) => {
|
|
reach(state => state.phase === 5 && state.active === actor && caladanFaceDanceClean(state));
|
|
const homeworldSources = nativeShipmentSources(viewGame(game, actor), amount, 0);
|
|
assert.ok(homeworldSources);
|
|
const before = structuredClone(game), step: CaladanFaceDanceStep = { actor,
|
|
action: { type: 'ship', territory, sector, amount, elite: 0, allyPayment: 0, homeworldSources } };
|
|
act(actor, step.action); reach(caladanFaceDanceClean);
|
|
shipments.push({ before, step, after: structuredClone(game) });
|
|
};
|
|
ship('a', 'polar_sink', 0, 4);
|
|
assert.equal(caladanFaceDancePopulation(game), 6);
|
|
ship('g', 'arrakeen', 10, 1);
|
|
reach(state => state.phase === 6 && caladanFaceDanceClean(state));
|
|
assert.ok(game.active === 'a' || game.active === 'g');
|
|
const beforeBattle = structuredClone(game), chooser = game.active;
|
|
const choice: CaladanFaceDanceStep = { actor: chooser, action: { type: 'chooseBattle', territory: 'arrakeen',
|
|
target: chooser === 'a' ? 'g' : 'a' } };
|
|
act(choice.actor, choice.action);
|
|
reach(state => !!state.battle && (!state.battle.preLeader || state.battle.preLeader.closed) &&
|
|
!state.battle.preparation && caladanFaceDanceClean(state));
|
|
const beforePlans = structuredClone(game), winningLeader = caladanFaceDancePlayer(game, 'a').leaders.find(leader => leader.id === 'atreides-0');
|
|
const losingLeader = [...caladanFaceDancePlayer(game, 'g').leaders]
|
|
.filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
|
|
assert.ok(winningLeader && losingLeader && winningLeader.strength > losingLeader.strength);
|
|
const plans: CaladanFaceDanceStep[] = [
|
|
{ actor: 'a', action: { type: 'battlePlan', leader: winningLeader.id, dial: 0, support: 0 } },
|
|
{ actor: 'g', action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0 } },
|
|
];
|
|
for (const step of plans) act(step.actor, step.action);
|
|
const sealed = structuredClone(game);
|
|
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'caladanReinforcement');
|
|
assert.equal(game.decision!.player, 'a');
|
|
assert.ok(game.pendingFaceDance);
|
|
assert.equal(game.pendingFaceDance.identity, winningLeader.id);
|
|
assert.equal(caladanFaceDancePopulation(game), 6);
|
|
assert.equal(caladanFaceDancePlayer(game, 'a').forces['arrakeen:10'], 10);
|
|
const offer = viewGame(game, 'a').caladanReinforcement;
|
|
assert.ok(offer && !offer.blocked, offer?.blocked ?? 'The original victory must allow Caladan before its pending Face Dance');
|
|
const target = offer.destinations.find(destination => destination.sector === 10 && !destination.blocked);
|
|
assert.ok(target, 'The real surviving Arrakeen army must be a reinforcement destination');
|
|
const quoted = caladanReinforcementChoice(viewGame(game, 'a'), target.id);
|
|
assert.ok(quoted.action, quoted.blocked ?? undefined);
|
|
const frame = structuredClone(game.homeworldVictoryReinforcement!);
|
|
return { game, advanced, owner: 'a', holder: 'a', dancer: 't', opponent: 'g', leader: winningLeader.id,
|
|
territory: 'arrakeen', location: 'arrakeen:10', event: frame.event, destination: target.id, action: quoted.action,
|
|
initial, setup, afterSetup, faceDancerDeal, shipments, beforeBattle, choice, beforePlans, plans, sealed,
|
|
originalInventory, originalPhysical: caladanFaceDancePhysical(game), originalFrame: frame,
|
|
originalFaceDance: structuredClone(game.pendingFaceDance), actions, sourceActionCount: actions.length,
|
|
staging: ['Only original unopened scalar/deck positions are selected. Public original Homeworld setup, private Traitor choices and native Face Dancer deal, Caladan4→Polar, Guild1→Arrakeen, real unarmed plans, votes and outcome acknowledgements produce this source. No played hand, force, wallet, leader, clock or receipt is installed.'] };
|
|
}
|
|
export type CaladanFaceDanceFixture = ReturnType<typeof createCaladanFaceDanceFixture>;
|
|
export function reinforceCaladanFaceDance(fixture: CaladanFaceDanceFixture, game = structuredClone(fixture.game), give = true,
|
|
actions: CaladanFaceDanceStep[] = []) {
|
|
const action: Action = give ? fixture.action : { type: 'decision', event: fixture.event, decline: true };
|
|
return actCaladanFaceDance(game, fixture.owner, action, actions);
|
|
}
|
|
export function faceDanceCaladanFaceDance(fixture: CaladanFaceDanceFixture, game: Game, reveal = true,
|
|
amount = caladanFaceDancePlayer(game, fixture.owner).forces[fixture.location] ?? 0,
|
|
actions: CaladanFaceDanceStep[] = []) {
|
|
assert.equal(game.decision?.kind, 'faceDance');
|
|
return actCaladanFaceDance(game, fixture.dancer, reveal
|
|
? { type: 'decision', reveal: true, sources: { reserves: amount }, sector: 10 }
|
|
: { type: 'decision', reveal: false }, actions);
|
|
}
|
|
export function finishCaladanFaceDance(fixture: CaladanFaceDanceFixture, game: Game,
|
|
actions: CaladanFaceDanceStep[] = []) {
|
|
return advanceCaladanFaceDance(game, state => !state.battle && !state.pendingFaceDance &&
|
|
state.homeworldVictoryReinforcement?.event === fixture.event &&
|
|
state.homeworldVictoryReinforcement.stage === 'complete' && caladanFaceDanceClean(state), actions);
|
|
}
|