import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, initializeSemutaGameForAudit, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries'; import { createDiscoveryStorm, createStormSource, discoveryStormDistance } from '../game/discovery-storm'; import { discoveryStormActions } from '../game/discovery-storm-options'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { semutaShrineLobby } from './fixture-semuta-shrine'; import { advanceTestingStationContest } from './fixture-testing-station-contest'; import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; import { reactSemutaSpecial } from './fixture-semuta-special-karama'; const SAPHO = 'richese-juice-of-sapho'; const STATION = 'ecological-testing-station'; const copy = (game: Game): Game => JSON.parse(JSON.stringify(game)); const seat = (game: Game, id: string) => { const player = game.players.find(row => row.id === id); assert.ok(player); return player; }; const cards = (game: Game) => [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); /** Genuine opt-in Richese/Discovery setup to first Storm. The original physical * station and two counters are staged at a controlled pending storm boundary; * actual unique Sapho and Semuta move from their original card zones. This is * not a natural Discovery reveal or full-game claim. */ function fixture(advanced: boolean, mode: 'first' | 'last') { let game = initializeSemutaGameForAudit(semutaShrineLobby(advanced)); game = advanceTestingStationContest(game, state => state.status === 'playing'); assert.equal(game.phase, 0); assert.equal(game.semutaPreview, true); const inventory = cards(game); assert.equal(inventory.filter(id => id === SAPHO).length, 1); assert.equal(inventory.filter(id => id === SEMUTA_DRUG_ID).length, 1); for (const player of game.players) game.deck.push(...player.hand.splice(0)); const token = game.discoveries!.tokens.find(row => row.face === STATION); assert.ok(token && token.status === 'supply'); const placement = Object.values(DISCOVERY_CARD_PLACEMENTS).find(row => row.type === token.type); assert.ok(placement); Object.assign(token, {status:'placed', territory:placement.territory, sector:placement.sector, revealedTurn:game.turn}); validateDiscoveryState(game.discoveries!); for (const id of ['e', 'g']) { const player = seat(game, id); assert.ok(player.reserves > 0); player.reserves--; player.forces[`${STATION}:0`] = (player.forces[`${STATION}:0`] ?? 0) + 1; } const order = game.order.filter(id => id === 'e' || id === 'g'); assert.equal(order.length, 2); const owner = order[mode === 'first' ? 1 : 0]; moveSemutaEmperorCard(game, SAPHO, owner); moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r'); assert.deepEqual(cards(game), inventory); 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, 'semuta-station-original'); game.decision = {kind:'ecologicalStorm', player:game.ecologicalStorm.owner, event:game.ecologicalStorm.event}; return {game, owner, order, inventory}; } for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) void test( `${advanced ? 'Advanced' : 'Basic'} ${mode} Sapho station choice resumes after Semuta claim or pass`, () => { const f = fixture(advanced, mode); const option = viewGame(f.game, f.owner).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === mode); assert.ok(option, 'The physical Sapho is eligible for the original station queue'); const action = {type:'card', card:SAPHO, scope:option.scope, event:option.event, mode:option.mode}; const pending = applyAction(copy(f.game), f.owner, action); assert.equal(pending.pendingTreacheryDiscard?.batch.cause, 'sapho:station'); assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoStationDiscard'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.deepEqual(pending.ecologicalStorm?.queue?.order, [...f.order].reverse()); assert.equal(pending.ecologicalStorm?.queue?.choices.length, 0); assert.equal(pending.stormPending, 3); assert.equal(pending.storm, f.game.storm); assert.equal(pending.decision, null, 'The original station choice waits behind the reaction'); const pendingEvent = pending.pendingTreacheryDiscard!.batch.event; assert.throws(() => applyAction(copy(pending), f.owner, action)); for (const claim of [false, true]) { let resumed = reactSemutaSpecial(copy(pending), 'r', claim); assert.equal(resumed.pendingTreacheryDiscard, null); assert.equal(resumed.decision?.kind, 'ecologicalStorm'); assert.equal(resumed.decision?.player, resumed.ecologicalStorm?.owner); assert.equal(resumed.decision?.event, resumed.ecologicalStorm?.event); assert.equal(seat(resumed, 'r').hand.some(card => card.id === SAPHO), claim); assert.equal(seat(resumed, 'r').hand.some(card => card.id === SEMUTA_DRUG_ID), !claim); assert.throws(() => applyAction(copy(resumed), 'r', {type:'semutaCommit', event:pendingEvent})); for (const delta of [1, -1]) { const owner = resumed.ecologicalStorm!.owner; const choice = discoveryStormActions(viewGame(resumed, owner)).find(row => row.delta === delta); assert.ok(choice); resumed = applyAction(resumed, owner, choice); } assert.equal(resumed.ecologicalStorm?.stage, 'complete'); assert.equal(discoveryStormDistance(resumed.ecologicalStorm!), 3); assert.equal(resumed.phase, 1); assert.deepEqual(cards(resumed), f.inventory); } }, ); void test('all four bot profiles can legally respond to the parked Sapho station discard', () => { const f = fixture(false, 'first'); const option = viewGame(f.game, f.owner).saphoOptions.find(row => row.scope === 'testingStation' && row.mode === 'first'); assert.ok(option); const pending = applyAction(f.game, f.owner, {type:'card', card:SAPHO, scope:option.scope, event:option.event, mode:option.mode}); for (const difficulty of DIFFICULTIES) { const view = viewGame(pending, 'r'); view.players.find(player => player.id === 'r')!.bot = difficulty; const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass'); assert.ok(action, `${difficulty} must offer a legal Semuta response`); assert.doesNotThrow(() => applyAction(copy(pending), 'r', action)); } });