import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { createSemutaHomeworldSaphoFixture } from './fixture-semuta-homeworld-sapho'; import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share'; import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding'; import { finishOriginalTruthQuestions, finishTruthOrder, playSaphoTruthOrder, truthOrderFrame } from './fixture-sapho-truth-order'; const copy = (game: Game): Game => JSON.parse(JSON.stringify(game)); const SAPHO = 'richese-juice-of-sapho'; const player = semutaEmperorPlayer; /** Genuine native Homeworld/Semuta setup and real Movement boundary. The * unique printed Sapho and Semuta plus two original Truthtrances move between * conserved card zones after setup to reach a rare simultaneous order; this is * not evidence of their natural purchase or initial distribution. */ function fixture(advanced: boolean, mode: 'first' | 'last') { const source = createSemutaHomeworldSaphoFixture('movement', mode, advanced); let game = copy(source.game); assert.ok(game.homeworlds?.custody && game.semutaPreview); const inventory = biddingPhysicalIds(game); for (const seat of game.players) game.deck.push(...seat.hand.splice(0)); const order = game.order.filter(id => id !== source.holder); assert.ok(order.length >= 2); const owner = mode === 'first' ? order.at(-1)! : order[0]; const other = mode === 'first' ? order[0] : order.at(-1)!; moveSemutaEmperorCard(game, SAPHO, owner); moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, source.holder); const originals = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)] .filter(card => card.effect === 'truthtrance').map(card => card.id); assert.ok(originals.length >= 2); const truth = moveSemutaEmperorCard(game, originals[0], owner); const otherTruth = moveSemutaEmperorCard(game, originals[1], other); assert.deepEqual(biddingPhysicalIds(game), inventory); const parent = {turn:game.turn, phase:game.phase, active:game.active, order:[...game.order], movementRemaining:structuredClone(game.movementRemaining)}; for (const [actor, card] of [[owner, truth], [other, otherTruth]]) game = applyAction(game, actor, {type:'card', card}); while (game.truthtrance?.stage === 'priority') { const seat = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(seat); game = applyAction(game, seat.id, {type:'truthPass'}); } const frame = truthOrderFrame(game); assert.deepEqual([...frame.holders].sort(), [owner, other].sort()); assert.equal(mode === 'first' ? game.truthtrance!.queue.at(-1)?.player : game.truthtrance!.queue[0].player, owner); return {game, owner, other, holder:source.holder, inventory, parent, truth, otherTruth}; } for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) void test( `${advanced ? 'Advanced' : 'Basic'} ${mode} Homeworld Truth order resumes after Semuta claim or pass`, () => { const f = fixture(advanced, mode), prior = copy(f.game), frame = truthOrderFrame(prior); assert.ok(viewGame(prior, f.owner).saphoTruthOrderChoices.some(choice => choice.card === SAPHO && choice.mode === mode)); assert.deepEqual(viewGame(prior, f.other).saphoTruthOrderChoices, []); assert.throws(() => applyAction(copy(prior), f.owner, {type:'saphoTruthOrder', event:'stale', revision:frame.revision, card:SAPHO, mode})); const pending = playSaphoTruthOrder(copy(prior), f.owner, SAPHO, mode); assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoTruthOrderDiscard'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(pending.pendingTreacheryDiscard?.batch.entries[0].card.id, SAPHO); assert.equal(pending.truthtrance, null); assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); const event = pending.pendingTreacheryDiscard!.batch.event; for (const claim of [false, true]) { let next = reactSemutaHomeworldChoam(copy(pending), f.holder, claim); assert.equal(next.pendingTreacheryDiscard, null); assert.equal(player(next, f.holder).hand.some(card => card.id === SAPHO), claim); assert.equal(player(next, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), !claim); const resumed = truthOrderFrame(next); assert.equal(resumed.event, frame.event); assert.ok(resumed.passed.includes(f.owner)); assert.throws(() => applyAction(copy(next), f.holder, {type:'semutaCommit', event})); next = finishTruthOrder(next); assert.equal(mode === 'first' ? next.truthtrance!.queue[0].player : next.truthtrance!.queue.at(-1)?.player, f.owner); next = finishOriginalTruthQuestions(next); assert.equal(next.truthtrance, null); assert.equal(next.turn, f.parent.turn); assert.equal(next.phase, f.parent.phase); assert.equal(next.active, f.parent.active); assert.deepEqual(next.order, f.parent.order); assert.deepEqual(next.movementRemaining, f.parent.movementRemaining); assert.deepEqual(biddingPhysicalIds(next), f.inventory); } }, ); void test('all bot profiles can legally answer the Homeworld Sapho Semuta offer', () => { const f = fixture(false, 'first'); const pending = playSaphoTruthOrder(copy(f.game), f.owner, SAPHO, 'first'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); for (const difficulty of DIFFICULTIES) { const view = viewGame(pending, f.holder); view.players.find(seat => seat.id === f.holder)!.bot = difficulty; const action = botActions(view).find(candidate => candidate.type === 'semutaCommit' || candidate.type === 'semutaPass'); assert.ok(action, `${difficulty} must resolve its own neutral opportunity`); assert.doesNotThrow(() => applyAction(copy(pending), f.holder, action)); } });