Files
Dune/tests/fixture-homeworld-skills-discovery.ts

257 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, DISCOVERY_TOKEN_BY_ID,
validateDiscoveryState, type DiscoveryLocationId, type DiscoveryOpaqueTokenId } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import type { SukReserveDestinations } from '../game/suk-graduate';
import {
assertClassicHomeworldSkillsCustody, classicHomeworldSkillsPlayer,
createClassicHomeworldSkillsSetup, nextClassicHomeworldSkillsStep,
type ClassicHomeworldSkillsSetup, type ClassicHomeworldSkillsStep,
} from './fixture-homeworld-classic-skills';
export type HomeworldSkillsDiscoveryStep = ClassicHomeworldSkillsStep;
export type HomeworldSkillsDiscoveryOptions = {
initial?: Game;
advanced?: boolean;
seats?: 2 | 3 | 4 | 5 | 6;
roster?: readonly FactionId[];
skill?: LeaderSkillId;
face?: DiscoveryLocationId;
shipForMaker?: boolean;
};
export type HomeworldSkillsDiscoveryFixture = ClassicHomeworldSkillsSetup & {
game: Game;
owner: string;
fremen: string;
opponent: string | null;
trainer: string;
token: DiscoveryOpaqueTokenId;
face: DiscoveryLocationId;
parent: string;
parentSector: number;
wormTerritory: string;
wormSector: number;
amount: number;
elite: number;
beforeShipment: Game;
afterShipment: Game;
};
export type HomeworldSkillsDiscoveryBattleFixture = HomeworldSkillsDiscoveryFixture & {
entered: Game;
beforeReinforcement: Game;
afterReinforcement: Game;
beforeBattle: Game;
plans: HomeworldSkillsDiscoveryStep[];
};
export const homeworldSkillsDiscoveryPlayer = classicHomeworldSkillsPlayer;
export const homeworldSkillsDiscoveryClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** An original fresh lobby, never a second reserve or component model. */
export function createHomeworldSkillsDiscoveryLobby(options: HomeworldSkillsDiscoveryOptions = {}): Game {
if (options.initial) return structuredClone(options.initial);
const factions: readonly FactionId[] = options.roster ?? ['emperor', 'fremen', 'guild', 'harkonnen', 'atreides', 'beneGesserit'];
let game = createGame('HWSKILLSDISCOVERY', newPlayer(factions[0], factions[0], factions[0]), options.advanced ?? false);
for (const faction of factions.slice(1, options.seats ?? (options.roster ? factions.length : 4))) joinGame(game, newPlayer(faction, faction, faction));
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
game.discoveryEnabled = true;
return game;
}
export function createHomeworldSkillsDiscoverySetup(options: HomeworldSkillsDiscoveryOptions = {}): ClassicHomeworldSkillsSetup {
return createClassicHomeworldSkillsSetup({ initial: createHomeworldSkillsDiscoveryLobby(options), skill: options.skill ?? 'suk-graduate' });
}
/** Consume only original owner-labelled queues; human plans and rescue choices
* remain explicit boundaries. The lottery helper below never signs a window. */
export function nextHomeworldSkillsDiscoveryStep(game: Game): HomeworldSkillsDiscoveryStep | null {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'greatMakerVote')
return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
if (decision?.kind === 'wormRide') return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'wormProtection') return { actor: decision.player, action: { type: 'decision', accept: true } };
}
return nextClassicHomeworldSkillsStep(game, game.players.find(p => p.faction === 'emperor')?.id);
}
export function advanceHomeworldSkillsDiscovery(state: Game, until: (game: Game) => boolean,
actions?: HomeworldSkillsDiscoveryStep[]): Game {
let game = state;
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextHomeworldSkillsDiscoveryStep(game);
assert.ok(next, 'A real sealed plan or physical Suk rescue choice is required.');
actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
}
throw Error('Original Homeworld/Skills/Discovery programme did not reach its owned window.');
}
export function assertHomeworldSkillsDiscoveryInventory(game: Game): void {
assertClassicHomeworldSkillsCustody(game);
validateDiscoveryState(game.discoveries!);
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceResolution?.skipped ?? []),
...(game.spiceSequence?.skipped ?? [])];
assert.equal(spice.filter(card => 'territory' in card && !!card.discovery).length, 6);
assert.equal(spice.filter(card => 'worm' in card && card.greatMaker).length, 1);
assert.equal(game.discoveries!.tokens.length, 8);
}
function frontUnplayedSpice(game: Game, choose: (card: Game['spiceDeck'][number]) => boolean, position: number): void {
const index = game.spiceDeck.findIndex((card, i) => i >= position && choose(card));
assert.ok(index >= position, 'Select only an original still-unplayed physical Spice Card.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
function originalTokenLottery<T>(game: Game, face: DiscoveryLocationId, operation: () => T): T {
const pool = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
const index = pool.findIndex(token => token.face === face); assert.ok(index >= 0);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = Math.floor((index + 0.5) / pool.length * 0x100000000);
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function act(game: Game, step: HomeworldSkillsDiscoveryStep, actions: HomeworldSkillsDiscoveryStep[]): Game {
actions.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
/** PDF21–23 variants together: original all14 training before faction setup,
* original seven Spice Cards/eight tokens, and original Homeworld withdrawals.
* Only undealt lotteries/unplayed Spice order are selected; no force staging. */
export function createHomeworldSkillsDiscoveryFixture(options: HomeworldSkillsDiscoveryOptions = {}): HomeworldSkillsDiscoveryFixture {
const setup = createHomeworldSkillsDiscoverySetup(options);
let game = structuredClone(setup.afterSetup);
const owner = game.players.find(p => p.faction === 'emperor')!.id;
const fremen = game.players.find(p => p.faction === 'fremen')!.id;
const opponent = game.players.find(p => p.faction === 'guild')?.id ?? null;
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
const face = options.face ?? 'cistern';
frontUnplayedSpice(game, card => 'territory' in card && card.discovery === 'discovery-hagga-basin', 0);
if (game.advanced) frontUnplayedSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', 1);
const nextTurnIndex = game.advanced ? 2 : 1;
frontUnplayedSpice(game, card => 'worm' in card && !!card.greatMaker, nextTurnIndex);
for (let position = nextTurnIndex + 1; position < nextTurnIndex + (game.advanced ? 3 : 2); position++)
frontUnplayedSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', position);
setup.staging.push('Selected only the conserved unplayed Hagga Discovery/next-turn Great Maker Spice order and the original Hiereg supply lottery; every force arrives through an engine action.');
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 1 && homeworldSkillsDiscoveryClean(g), setup.actions);
game = originalTokenLottery(game, face, () => advanceHomeworldSkillsDiscovery(game,
g => g.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'), setup.actions));
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 5 && g.active === owner && homeworldSkillsDiscoveryClean(g), setup.actions);
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const blow = DISCOVERY_SPICE_CARDS.find(card => card.discovery === 'discovery-hagga-basin')!;
const destination = options.shipForMaker ? blow : placement;
const amount = game.advanced ? 3 : 2, elite = game.advanced ? 1 : 0;
const beforeShipment = structuredClone(game), sources = nativeShipmentSources(viewGame(game, owner), amount, elite); assert.ok(sources);
game = act(game, { actor: owner, action: { type: 'ship', territory: destination.territory,
sector: destination.sector, amount, elite, allyPayment: 0, homeworldSources: sources } }, setup.actions);
game = advanceHomeworldSkillsDiscovery(game, g => !g.pendingShipment && homeworldSkillsDiscoveryClean(g), setup.actions);
const afterShipment = structuredClone(game);
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 7 && homeworldSkillsDiscoveryClean(g), setup.actions);
const token = game.discoveries!.tokens.find(t => t.face === face)!;
assert.equal(game.turn, 1); assertHomeworldSkillsDiscoveryInventory(game);
return { ...setup, game, owner, fremen, opponent, trainer, token: token.id, face,
parent: placement.territory, parentSector: placement.sector, wormTerritory: blow.territory,
wormSector: blow.sector, amount, elite, beforeShipment, afterShipment };
}
export function revealHomeworldSkillsDiscovery(fixture: HomeworldSkillsDiscoveryFixture): Game {
let game = structuredClone(fixture.game);
if (viewGame(game, fixture.owner).discoveries!.canInspect.includes(fixture.token))
game = applyAction(game, fixture.owner, { type: 'discovery', token: fixture.token, reveal: false });
assert.ok(viewGame(game, fixture.owner).discoveries!.canReveal.includes(fixture.token));
return applyAction(game, fixture.owner, { type: 'discovery', token: fixture.token, reveal: true });
}
export function homeworldSkillsDiscoveryEntryWindow(fixture: HomeworldSkillsDiscoveryFixture): Game {
return advanceHomeworldSkillsDiscovery(revealHomeworldSkillsDiscovery(fixture), game =>
game.turn === 2 && game.decision?.kind === 'discoveryEntry' && game.decision.player === fixture.owner);
}
export function enterHomeworldSkillsDiscovery(fixture: HomeworldSkillsDiscoveryFixture): Game {
const game = homeworldSkillsDiscoveryEntryWindow(fixture), view = viewGame(game, fixture.owner);
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
return applyAction(game, fixture.owner, action);
}
export function homeworldSkillsDiscoveryMakerWindow(fixture: HomeworldSkillsDiscoveryFixture): Game {
return advanceHomeworldSkillsDiscovery(structuredClone(fixture.game), game => game.decision?.kind === 'greatMakerVote');
}
/** Genuine reveal -> next-turn parent entry -> paid reinforcement and enemy
* shipment -> nested battle. Neither physical army is placed by the fixture. */
export function createHomeworldSkillsDiscoveryBattleFixture(options: HomeworldSkillsDiscoveryOptions = {}): HomeworldSkillsDiscoveryBattleFixture {
const fixture = createHomeworldSkillsDiscoveryFixture(options);
assert.ok(fixture.opponent, 'The connected nested battle needs the original Guild seat.');
let game = enterHomeworldSkillsDiscovery(fixture);
const entered = structuredClone(game), reinforcement = game.advanced ? 2 : 3;
const beforeByActor = new Map<string, Game>(), afterByActor = new Map<string, Game>();
// Original storm order determines which real shipment happens first.
game = advanceHomeworldSkillsDiscovery(game, g => g.turn === 2 && g.phase === 5 && homeworldSkillsDiscoveryClean(g), fixture.actions);
const remaining = new Set([fixture.owner, fixture.opponent]);
while (remaining.size) {
game = advanceHomeworldSkillsDiscovery(game, g => g.turn === 2 && g.phase === 5 &&
homeworldSkillsDiscoveryClean(g) && remaining.has(g.active!), fixture.actions);
const actor = game.active!, amount = actor === fixture.owner ? reinforcement : 2;
beforeByActor.set(actor, structuredClone(game));
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(sources);
game = act(game, { actor, action: { type: 'ship', territory: fixture.face, sector: 0,
amount, elite: 0, allyPayment: 0, homeworldSources: sources } }, fixture.actions);
game = advanceHomeworldSkillsDiscovery(game, g => !g.pendingShipment && homeworldSkillsDiscoveryClean(g), fixture.actions);
afterByActor.set(actor, structuredClone(game)); remaining.delete(actor);
game = act(game, { actor, action: { type: 'endMovement' } }, fixture.actions);
}
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 6 && homeworldSkillsDiscoveryClean(g), fixture.actions);
const beforeBattle = structuredClone(game), actor = game.active!;
assert.ok(actor === fixture.owner || actor === fixture.opponent);
game = act(game, { actor, action: { type: 'chooseBattle', territory: fixture.face,
target: actor === fixture.owner ? fixture.opponent : fixture.owner } }, fixture.actions);
game = advanceHomeworldSkillsDiscovery(game, g => !!g.battle && homeworldSkillsDiscoveryClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, fixture.actions);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const enemy = classicHomeworldSkillsPlayer(game, fixture.opponent);
const leader = enemy.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(leader);
const plans: HomeworldSkillsDiscoveryStep[] = [
{ actor: fixture.owner, action: { type: 'battlePlan', leader: fixture.trainer, dial: 4, support: game.advanced ? 2 : 0 } },
{ actor: fixture.opponent, action: { type: 'battlePlan', leader: leader.id, dial: 0 } },
];
assertHomeworldSkillsDiscoveryInventory(game);
return { ...fixture, game, entered, beforeReinforcement: beforeByActor.get(fixture.owner)!,
afterReinforcement: afterByActor.get(fixture.owner)!, beforeBattle, plans };
}
export function revealHomeworldSkillsDiscoveryBattle(fixture: HomeworldSkillsDiscoveryBattleFixture): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.plans) {
game = applyAction(game, step.actor, step.action);
if (!game.battle?.revealed) game = advanceHomeworldSkillsDiscovery(game,
g => homeworldSkillsDiscoveryClean(g) && !!g.battle && !g.battle.preparation);
}
assert.ok(game.battle?.revealed); return game;
}
export function settleHomeworldSkillsDiscoveryBattle(state: Game, stop: 'suk' | 'complete' = 'complete'): Game {
return advanceHomeworldSkillsDiscovery(state, g => stop === 'suk' && g.decision?.kind === 'sukRescue' ||
!g.battle && homeworldSkillsDiscoveryClean(g));
}
export function homeworldSkillsDiscoveryRescueChoice(game: Game, kept: 'normal' | 'elite' = 'normal'): number {
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
const maximum = Math.max(...decision.options.map(o => o.normal + o.elite));
const choice = decision.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === kept);
assert.ok(choice >= 0); return choice;
}
export function homeworldSkillsDiscoverySplitReturn(game: Game, choice: number): SukReserveDestinations | undefined {
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
if (!decision.reserveHomes) return undefined;
const option = decision.options[choice], normal = option.normal - (option.kept?.kind === 'normal' ? 1 : 0),
elite = option.elite - (option.kept?.kind === 'elite' ? 1 : 0);
if (!normal && !elite) return undefined;
// Explicit prototype placement interpretation, not printed revival placement:
// return ordinary pieces to Salusa and Sardaukar to Kaitain, without moving a native saved counter.
return Object.fromEntries(decision.reserveHomes.map(home => [home.id,
home.secondary ? { normal, elite: 0 } : { normal: 0, elite }]));
}