import assert from 'node:assert/strict'; import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, } from '../game/engine'; import { homeworldDiscoveryProfile } from '../game/discovery-module-profile'; 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 { homeworldGameIntegrity } from '../game/homeworld-game'; import { nativeShipmentSources } from '../game/homeworld-options'; import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income'; export type HomeworldClassicDiscoveryStep = { actor: string; action: Action }; export type HomeworldClassicDiscoveryOptions = { /** Original fresh lobby/staged setup; existing first hands are not redealt. */ initial?: Game; advanced?: boolean; seats?: 2 | 3 | 4 | 5 | 6; face?: DiscoveryLocationId; /** Exercise the previous blow army, distinct from the token's printed parent. */ shipForMaker?: boolean; /** Real turn-one movement supplies a later split ordinary-reserve shipment. */ prepareImperialSplit?: boolean; }; export type HomeworldClassicDiscoveryFixture = { initial: Game; setup: Game; afterSetup: Game; game: Game; actions: HomeworldClassicDiscoveryStep[]; staging: string[]; collector: string; fremen: string; token: DiscoveryOpaqueTokenId; face: DiscoveryLocationId; parent: string; parentSector: number; amount: number; elite: number; wormTerritory: string; wormSector: number; }; export const homeworldClassicDiscoveryClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard; export const homeworldClassicDiscoveryPlayer = (game: Game, actor: string) => { const player = game.players.find(player => player.id === actor); assert.ok(player); return player; }; /** Public owned continuations only. Storm dials use their original legal range; * no phase clock, casualty, hand, balance or signed Discovery frame is staged. */ export function nextHomeworldClassicDiscoveryStep(game: Game): HomeworldClassicDiscoveryStep { if (game.status === 'playing' && game.phase === 2 && homeworldClassicDiscoveryClean(game) && game.charityOrder?.remaining.length) return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } }; if (!game.phaseOpening && !game.response) { const decision = game.decision; if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } }; 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 } }; } const next = nextSpiceBankerIncomeNativeStep(game); assert.ok(next, 'Original Homeworld/Discovery continuation needs an owned legal action.'); return next; } export function advanceHomeworldClassicDiscovery(state: Game, until: (game: Game) => boolean, actions?: HomeworldClassicDiscoveryStep[]): Game { let game = state; for (let i = 0; !until(game) && i < 1800; i++) { assert.notEqual(game.status, 'finished'); const next = nextHomeworldClassicDiscoveryStep(game); actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action); } assert.ok(until(game), 'Original Homeworld/Discovery programme did not reach its action window.'); return game; } export function homeworldClassicDiscoveryInventory(game: Game): void { homeworldGameIntegrity(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); const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]; assert.equal(cards.length, 33); assert.equal(new Set(cards.map(card => card.id)).size, 33); for (const player of game.players) if (player.elites) { assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((a, b) => a + b, 0), player.faction === 'fremen' ? 3 : 5); } } function selectedTokenLottery(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 native = crypto.getRandomValues.bind(crypto), descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); crypto.getRandomValues = (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 frontSpice(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, 'The original unplayed physical Spice Card must remain available.'); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); } /** Root rulebook physical pp.22–23: reserves/Homeworlds, Emperor split sources, * seven Spice Cards/eight tokens, actual printed blow and Collection reveal. * Only original unplayed Spice order and the original supply lottery are selected. */ export function createHomeworldClassicDiscoveryFixture(options: HomeworldClassicDiscoveryOptions = {}): HomeworldClassicDiscoveryFixture { let game: Game; if (options.initial) game = structuredClone(options.initial); else { const factions = ['emperor', 'fremen', 'guild', 'harkonnen', 'atreides', 'beneGesserit'] as const; game = createGame('HWCLASSICDISCOVERY', newPlayer('emperor', 'Emperor', 'emperor'), options.advanced ?? false); for (const faction of factions.slice(1, options.seats ?? 4)) joinGame(game, newPlayer(faction, faction, faction)); game = applyAction(game, game.host, { type: 'homeworlds', enabled: true }); game.discoveryEnabled = true; } assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0)); assert.ok(homeworldDiscoveryProfile(game)); const collector = game.players.find(player => player.faction === 'emperor')!.id; const fremen = game.players.find(player => player.faction === 'fremen')!.id; assert.ok(collector && fremen); const actions: HomeworldClassicDiscoveryStep[] = []; if (game.status === 'lobby') for (const player of game.players) if (!player.ready) { const next: HomeworldClassicDiscoveryStep = { actor: player.id, action: { type: 'ready' } }; actions.push(next); game = applyAction(game, next.actor, next.action); } const initial = structuredClone(game); if (game.status === 'lobby') game = initializeDiscoveryGameForAudit(game); const setup = structuredClone(game); game = advanceHomeworldClassicDiscovery(game, state => state.status === 'playing', actions); const afterSetup = structuredClone(game), face = options.face ?? 'cistern'; frontSpice(game, card => 'territory' in card && card.discovery === 'discovery-hagga-basin', 0); // Advanced consumes two piles; Basic consumes one. The next original Great // Maker therefore meets pile zero's original Hagga Basin blow on turn two. if (game.advanced) frontSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', 1); const nextTurnIndex = game.advanced ? 2 : 1; frontSpice(game, card => 'worm' in card && !!card.greatMaker, nextTurnIndex); for (let position = nextTurnIndex + 1; position < nextTurnIndex + (game.advanced ? 3 : 2); position++) frontSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', position); const staging = ['Reordered conserved original unplayed Spice Cards for the printed Hagga Basin Discovery blow and turn-two Great Maker; original starting hands/Treachery deck untouched.', `Selected ${face} through the actual original Hiereg supply-token lottery; no reveal, force position or Discovery continuation injected.`]; game = advanceHomeworldClassicDiscovery(game, state => state.phase === 1 && homeworldClassicDiscoveryClean(state), actions); game = selectedTokenLottery(game, face, () => advanceHomeworldClassicDiscovery(game, state => state.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions)); game = advanceHomeworldClassicDiscovery(game, state => state.phase === 5 && state.active === collector && homeworldClassicDiscoveryClean(state), 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 sources = nativeShipmentSources(viewGame(game, collector), amount, elite); assert.ok(sources); const shipment: HomeworldClassicDiscoveryStep = { actor: collector, action: { type: 'ship', territory: destination.territory, sector: destination.sector, amount, elite, allyPayment: 0, homeworldSources: sources } }; actions.push(shipment); game = applyAction(game, collector, shipment.action); if (game.advanced && options.prepareImperialSplit) { game = advanceHomeworldClassicDiscovery(game, state => !state.pendingShipment && homeworldClassicDiscoveryClean(state), actions); const movement = viewGame(game, collector).homeworldMove!; const transfer: HomeworldClassicDiscoveryStep = { actor: collector, action: { type: 'emperorHomeworldMove', event: movement.event, origin: 'homeworld:emperor', normal: 1, elite: 0, } }; actions.push(transfer); game = applyAction(game, collector, transfer.action); } game = advanceHomeworldClassicDiscovery(game, state => state.phase === 7 && homeworldClassicDiscoveryClean(state), actions); const token = game.discoveries!.tokens.find(token => token.face === face)!; assert.equal(game.turn, 1); homeworldClassicDiscoveryInventory(game); return { initial, setup, afterSetup, game, actions, staging, collector, fremen, token: token.id, face, parent: placement.territory, parentSector: placement.sector, amount, elite, wormTerritory: blow.territory, wormSector: blow.sector }; } export function revealHomeworldClassicDiscovery(fixture: HomeworldClassicDiscoveryFixture): Game { let game = fixture.game; if (viewGame(game, fixture.collector).discoveries!.canInspect.includes(fixture.token)) game = applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: false }); assert.ok(viewGame(game, fixture.collector).discoveries!.canReveal.includes(fixture.token)); return applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: true }); } export function homeworldClassicDiscoveryEntryWindow(fixture: HomeworldClassicDiscoveryFixture): Game { return advanceHomeworldClassicDiscovery(revealHomeworldClassicDiscovery(fixture), game => game.turn === 2 && game.decision?.kind === 'discoveryEntry' && game.decision.player === fixture.collector); } export function enterHomeworldClassicDiscovery(fixture: HomeworldClassicDiscoveryFixture): Game { const game = homeworldClassicDiscoveryEntryWindow(fixture), view = viewGame(game, fixture.collector); const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action); return applyAction(game, fixture.collector, action); } export function homeworldClassicDiscoveryShipmentWindow(state: Game, owner: string): Game { return advanceHomeworldClassicDiscovery(state, game => game.turn === 2 && game.phase === 5 && game.active === owner && homeworldClassicDiscoveryClean(game)); } export function settleHomeworldClassicDiscoveryArrival(state: Game): Game { return advanceHomeworldClassicDiscovery(state, game => !game.pendingShipment && homeworldClassicDiscoveryClean(game)); } export function homeworldClassicGreatMakerVoteWindow(fixture: HomeworldClassicDiscoveryFixture): Game { return advanceHomeworldClassicDiscovery(fixture.game, game => game.decision?.kind === 'greatMakerVote'); }