import assert from 'node:assert/strict'; import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries'; import type { NoFieldValue } from '../game/richese-no-field'; import { actFamilyAtomicsNoField, familyAtomicsNoFieldPlayer, familyAtomicsNoFieldReload, familyAtomicsNoFieldInventory, familyAtomicsNoFieldClean, nextFamilyAtomicsNoFieldStep, type FamilyAtomicsNoFieldStep } from './fixture-family-atomics-no-field'; export type DiscoveryNoFieldBlowStep = FamilyAtomicsNoFieldStep; export const actDiscoveryNoFieldBlow = actFamilyAtomicsNoField; export const discoveryNoFieldBlowPlayer = familyAtomicsNoFieldPlayer; export const discoveryNoFieldBlowReload = familyAtomicsNoFieldReload; export const discoveryNoFieldBlowClean = familyAtomicsNoFieldClean; export function discoveryNoFieldBlowInventory(game: Game) { const original = familyAtomicsNoFieldInventory(game); validateDiscoveryState(game.discoveries!); return original; } export function nextDiscoveryNoFieldBlow(game: Game): DiscoveryNoFieldBlowStep | null { if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { const d = game.decision; if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } }; if (d?.kind === 'richeseAllyOpportunity') return { actor: d.player, action: { type: 'decision', decline: true } }; if (d?.kind === 'discoveryEntry' || d?.kind === 'greatMakerRide') return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } }; } // The inherited native phase5 branch explicitly ends the current turn before // optional policy actions, including newly supported No-Field transport. return nextFamilyAtomicsNoFieldStep(game); } export function advanceDiscoveryNoFieldBlow(game: Game, until: (state: Game) => boolean, actions: DiscoveryNoFieldBlowStep[] = []): Game { for (let count = 0; count < 1800; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished', 'The original Discovery chronology ended before its requested boundary'); const next = nextDiscoveryNoFieldBlow(game); assert.ok(next, 'Complete the actual native pending control'); game = actDiscoveryNoFieldBlow(game, next.actor, next.action, actions); } throw Error('The original Discovery/No-Field chronology did not reach its finite boundary'); } export function settleDiscoveryNoFieldBlow(game: Game, actions: DiscoveryNoFieldBlowStep[] = []): Game { return advanceDiscoveryNoFieldBlow(game, discoveryNoFieldBlowClean, actions); } export function finishDiscoveryNoFieldBlowPhase(game: Game, actions: DiscoveryNoFieldBlowStep[] = []): Game { return advanceDiscoveryNoFieldBlow(game, state => state.phase !== 1 && discoveryNoFieldBlowClean(state), actions); } const printed = DISCOVERY_SPICE_CARDS.find(card => card.discovery === 'discovery-wind-pass-north')!; function selectLand(game: Game, position: number) { const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && card.territory !== printed.territory && card.sector !== game.storm); assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) }; } /** Public opt-in and actual shipments only. Marker-only sources use turn1 * shipment and turn2 original blow; mixed sources first ship physical1, then * the marker on turn2, and wait for the actual turn3 blow. No audit initializer * or installed hand, wallet, army, phase, token, event or receipt is used. */ export function createDiscoveryNoFieldBlowFixture(options: { advanced?: boolean; value?: NoFieldValue; companions?: boolean; } = {}) { const advanced = options.advanced ?? true, value = options.value ?? 0, companions = options.companions ?? false; const owner = 'richese', other = 'emperor', observer = 'atreides'; let game = createGame('DISCOVERYNFBLOW', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']); joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(other, 'Emperor', 'emperor')); const initial = structuredClone(game), actions: DiscoveryNoFieldBlowStep[] = []; const act = (actor: string, action: Action) => { game = actDiscoveryNoFieldBlow(game, actor, action, actions); }; const reach = (until: (state: Game) => boolean) => { game = advanceDiscoveryNoFieldBlow(game, until, actions); }; const transition = (run: () => void) => { const before = structuredClone(game), from = actions.length; run(); return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; }; act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true }); for (const player of game.players) act(player.id, { type: 'ready' }); const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length)); const ordinary = ['projectile', 'shield', 'snooper'].map(kind => game.deck.find(card => card.kind === kind)); const dealOrder = ordinary.map((card, position) => { assert.ok(card); const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; return { position, from, card: card.id }; }); reach(state => state.status === 'playing'); const afterSetup = structuredClone(game), setupActions = structuredClone(actions); assert.equal(game.discoveryEnabled, true); assert.ok(game.discoveries); assert.deepEqual(discoveryNoFieldBlowPlayer(game, owner).forces, {}); const original = discoveryNoFieldBlowInventory(game), originalDiscovery = structuredClone(game.discoveries); const firstSpiceOrder = [selectLand(game, 0), ...(advanced ? [selectLand(game, 1)] : [])]; reach(state => state.turn === 1 && state.phase === 5 && discoveryNoFieldBlowClean(state)); let markerShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null; let physicalShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null; let otherShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null; let token = ''; const shipMarker = () => { const own = discoveryNoFieldBlowPlayer(game, owner), selected = own.noField!.tokens.find(token => token.value === value); assert.ok(selected && own.noFieldEvent); token = selected.id; markerShipment = transition(() => { act(owner, { type: 'ship', noField: token, event: own.noFieldEvent, territory: printed.territory, sector: printed.sector }); reach(discoveryNoFieldBlowClean); }); assert.equal(discoveryNoFieldBlowPlayer(markerShipment.before, owner).spice - discoveryNoFieldBlowPlayer(game, owner).spice, 2); assert.equal(discoveryNoFieldBlowPlayer(markerShipment.before, owner).reserves, discoveryNoFieldBlowPlayer(game, owner).reserves); }; for (let count = 0; game.phase === 5 && count < 80; count++) { if (discoveryNoFieldBlowClean(game) && game.active === owner && !discoveryNoFieldBlowPlayer(game, owner).shipped) { if (companions) physicalShipment = transition(() => { act(owner, { type: 'ship', territory: printed.territory, sector: printed.sector, amount: 1, elite: 0, allyPayment: 0 }); reach(discoveryNoFieldBlowClean); }); else shipMarker(); continue; } if (discoveryNoFieldBlowClean(game) && game.active === other && !discoveryNoFieldBlowPlayer(game, other).shipped) { otherShipment = transition(() => { act(other, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 }); reach(discoveryNoFieldBlowClean); }); continue; } const next = nextDiscoveryNoFieldBlow(game); assert.ok(next); act(next.actor, next.action); } assert.ok(otherShipment); assert.ok(!companions || physicalShipment); reach(state => state.turn === 2 && state.phase === 0 && discoveryNoFieldBlowClean(state)); let secondSpiceOrder: ReturnType[] = []; if (companions) { secondSpiceOrder = [selectLand(game, 0), ...(advanced ? [selectLand(game, 1)] : [])]; reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && discoveryNoFieldBlowClean(state)); shipMarker(); reach(state => state.turn === 3 && state.phase === 0 && discoveryNoFieldBlowClean(state)); } assert.ok(markerShipment); const shipment = markerShipment as { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game }; const beforeTargetStorm = structuredClone(game); const from = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed.discovery); assert.ok(from >= 0); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.unshift(card); const blowSelection = { position: 0, from, card: structuredClone(card) }; const followingSpiceOrder = advanced ? [selectLand(game, 1)] : []; const turn = companions ? 3 : 2; reach(state => state.turn === turn && state.phase === 1 && discoveryNoFieldBlowClean(state) && !state.spiceWindow && !state.spiceSequence && !state.spiceResolution); const opening = structuredClone(game), readyFrom = actions.length; assert.equal(game.ready.length, 0); for (const player of game.players.slice(0, -1)) act(player.id, { type: 'ready' }); const readyPrefix = structuredClone(actions.slice(readyFrom)); const drawer = game.players.at(-1)!.id, drawAction: Action = { type: 'ready' }; assert.equal(game.spiceWindow ?? null, null); assert.equal(game.thumperWindowOpen, true); assert.deepEqual(game.spiceDeck[0], printed); assert.equal(game.ready.length, game.players.length - 1); assert.equal(game.ready.includes(drawer), false); const own = discoveryNoFieldBlowPlayer(game, owner), event = own.noFieldEvent; assert.ok(event); assert.equal(own.noField!.deployed!.tokenId, token); assert.equal(event, discoveryNoFieldBlowPlayer(shipment.after, owner).noFieldEvent); assert.equal(own.forces[`${printed.territory}:${printed.sector}`] ?? 0, companions ? 1 : 0); assert.equal(own.tanks, 0); assert.equal(discoveryNoFieldBlowPlayer(game, other).forces['polar_sink:0'], 1); assert.deepEqual(discoveryNoFieldBlowInventory(game), original); assert.deepEqual(game.discoveries, originalDiscovery); return { game, beforeBlow: structuredClone(game), opening, initial, lobby, setup, afterSetup, advanced, value, companions, owner, actor: drawer, drawer, other, observer, token, event, marker: structuredClone(own.noField), printed, target: printed.territory, location: `${printed.territory}:${printed.sector}`, placement: DISCOVERY_CARD_PLACEMENTS[printed.discovery], drawAction, drawStep: { actor: drawer, action: drawAction }, markerShipment: shipment, physicalShipment, otherShipment, beforeTargetStorm, original, originalDiscovery, dealOrder, firstSpiceOrder, secondSpiceOrder, blowSelection, followingSpiceOrder, readyPrefix, setupActions, actions, sourceActions: actions, sourceActionCount: actions.length, sourceCounts: { setup: setupActions.length, markerShipment: shipment.steps.length, physicalShipment: physicalShipment?.steps.length ?? 0, otherShipment: otherShipment.steps.length, readyPrefix: readyPrefix.length, source: actions.length }, sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor +CHOAM deck, public Discovery moduleOption before readiness/start, no audit initializer or other optional module. ` + 'Only original unopened Treachery/Spice positions and scalar lotteries are selected. Richese pays two spice for its original concealed0/3/5 token; physical reserves remain untouched. Emperor separately pays2 for one original Polar counter. ' + (companions ? 'Richese first actually ships physical1 to WindPassNorth17 on turn1, then its marker there on turn2, and reaches the original turn3 Discovery blow. ' : 'Richese actually ships the marker to WindPassNorth17 on turn1 and reaches the original turn2 Discovery blow. ') + 'Native dials/Storm, quiet auctions, ordered Advanced Charity/Revival and actual turn ends preserve the same deployed event. Two actual Ready controls prime the original first-reveal window; the exposed final Ready performs the printed draw. No hand, purse, force, phase, token, marker event, spice, discovery placement or casualty is installed.' }; } export type DiscoveryNoFieldBlowFixture = ReturnType;