import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries'; import { createDiscoveryStorm, createStormSource, discoveryStormDistance } from '../game/discovery-storm'; import { discoveryStormActions } from '../game/discovery-storm-options'; import { advanceTestingStationContest } from './fixture-testing-station-contest'; const CARD = 'richese-juice-of-sapho'; const STATION = 'ecological-testing-station'; function seat(game: Game, id: string) { const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player; } function physicalCards(game: Game) { return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)]; } /** A real lobby selects CHOAM expansion and Discovery through public actions, * starts and completes the original faction setup. Only after setup, the * revealed physical station, two ordinary reserve forces, Storm source and * its unchosen queue are staged to reach the rare contested boundary; one * existing physical Sapho moves from Richese's cache into its intended owner. * This is not a natural full game, purchase, reveal, or collection proof. */ function fixture(advanced: boolean) { let game = createGame('SAPHOSTATIONPUBLIC', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']); for (const faction of ['atreides', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction)); game = applyAction(game, game.host, {type:'moduleOption', module:'discoveries', enabled:true}); for (const player of game.players) game = applyAction(game, player.id, {type:'ready'}); game = applyAction(game, game.host, {type:'start', ...(advanced ? {advancedPreview:true} : {})}); assert.equal(game.status, 'setup'); assert.equal(game.discoveryEnabled, true); assert.deepEqual(game.expansions, ['choam']); assert.ok(game.discoveries && game.richeseCache); const startInventory = physicalCards(game).map(card => card.id).sort(); assert.equal(startInventory.filter(id => id === CARD).length, 1); game = advanceTestingStationContest(game, state => state.status === 'playing'); assert.equal(game.phase, 0); const token = game.discoveries!.tokens.find(candidate => candidate.face === STATION); assert.ok(token && token.status === 'supply'); const position = Object.values(DISCOVERY_CARD_PLACEMENTS).find(candidate => candidate.type === token.type); assert.ok(position); Object.assign(token, {status:'placed', territory:position.territory, sector:position.sector, revealedTurn:game.turn}); validateDiscoveryState(game.discoveries!); for (const id of ['atreides', 'emperor']) { const player = seat(game, id); assert.ok(player.reserves > 0); player.reserves--; player.forces[`${STATION}:0`] = (player.forces[`${STATION}:0`] ?? 0) + 1; } const occupants = game.order.filter(id => id === 'atreides' || id === 'emperor'); assert.equal(occupants.length, 2); const holder = occupants[1]; const index = game.richeseCache!.findIndex(card => card.id === CARD); assert.ok(index >= 0); const [sapho] = game.richeseCache!.splice(index, 1); seat(game, holder).hand.push(sapho); assert.deepEqual(physicalCards(game).map(card => card.id).sort(), startInventory); game.stormPending = 3; game.stormMovementSource = createStormSource(game.turn, 'dials', 3); game.ready = game.players.map(player => player.id); game.phaseOpening = null; game.stormResolution = null; game.ecologicalStorm = createDiscoveryStorm(game, 'public-station-setup'); game.decision = {kind:'ecologicalStorm', player:game.ecologicalStorm.owner, event:game.ecologicalStorm.event}; return {game, holder, occupants, startInventory}; } for (const advanced of [false, true]) void test( `${advanced ? 'Advanced' : 'Basic'} public Richese/Discovery start reaches staged conserved Sapho station use`, () => { const f = fixture(advanced), prior = structuredClone(f.game); const option = viewGame(prior, f.holder).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === 'first'); assert.ok(option, 'The existing physical Sapho can take the pending station choice first'); let game = applyAction(prior, f.holder, {type:'card', card:CARD, scope:option.scope, event:option.event, mode:option.mode}); assert.deepEqual(game.order, prior.order); assert.deepEqual(game.ecologicalStorm?.queue?.order, [...f.occupants].reverse()); assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'sapho:station'); const discardEvent = game.pendingTreacheryDiscard!.batch.event; for (const player of game.players) game = applyAction(game, player.id, {type:'semutaPass', event:discardEvent}); assert.equal(game.decision?.player, f.holder); assert.equal(game.discard.filter(card => card.id === CARD).length, 1); for (const delta of [1, -1]) { const actor = game.ecologicalStorm!.owner; const action = discoveryStormActions(viewGame(game, actor)).find(row => row.delta === delta); assert.ok(action); game = applyAction(game, actor, action); } assert.equal(game.ecologicalStorm?.stage, 'complete'); assert.equal(discoveryStormDistance(game.ecologicalStorm!), 3); assert.deepEqual(physicalCards(game).map(card => card.id).sort(), f.startInventory); }, );