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 { semutaSkillsInventory } from './fixture-semuta-skills'; import { finishOriginalTruthQuestions, finishTruthOrder } from './fixture-sapho-truth-order'; import { selectedDiscoverySkillsStart, advanceDiscoverySkillsBlow } from './selected-discovery-skills-fixture'; const SAPHO = 'richese-juice-of-sapho'; const seat = (game: Game, id: string) => { const player = game.players.find(row => row.id === id); assert.ok(player); return player; }; /** Real public mixed-deck setup, training and Discovery blow. Reorder only * unopened Spice Cards and conserve every physical Treachery Card while * staging the two simultaneous question holders and Sapho/Semuta custody. */ function fixture(advanced: boolean) { const started = selectedDiscoverySkillsStart({advanced, factions:['richese', 'ecaz', 'atreides', 'emperor']}); assert.equal(started.setup.semutaPreview, true); assert.ok(started.game.leaderSkills && started.game.discoveries); let game = advanceDiscoverySkillsBlow(started.game, started.actions); const inventory = semutaSkillsInventory(game); for (const player of game.players) game.deck.push(...player.hand.splice(0)); const give = (id: string, identity: string) => { const zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(row => row.hand)]; const source = zones.find(zone => zone.some(card => card.id === identity || card.effect === identity)); assert.ok(source, `Missing original ${identity}`); const [card] = source.splice(source.findIndex(row => row.id === identity || row.effect === identity), 1); seat(game, id).hand.push(card); return card.id; }; const ordered = game.order.filter(id => id === 'atreides' || id === 'emperor'); assert.equal(ordered.length, 2); const [other, holder] = ordered; give('richese', SEMUTA_DRUG_ID); give(holder, SAPHO); const truth = give(holder, 'truthtrance'), otherTruth = give(other, 'truthtrance'); assert.deepEqual(semutaSkillsInventory(game), inventory); const parent = {turn:game.turn, phase:game.phase, active:game.active, order:[...game.order], movementRemaining:structuredClone(game.movementRemaining)}; for (const [id, card] of [[holder, truth], [other, otherTruth]]) game = applyAction(game, id, {type:'card', card}); while (game.truthtrance?.stage === 'priority') { const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor); game = applyAction(game, actor.id, {type:'truthPass'}); } assert.equal(game.truthtrance?.stage, 'order'); return {game, holder, parent, inventory}; } for (const advanced of [false, true]) void test( `${advanced ? 'Advanced' : 'Basic'} mixed Discovery Skills preserves Sapho Truth-order through Semuta`, () => { const f = fixture(advanced), frame = f.game.truthtrance!.order!; assert.ok(viewGame(f.game, f.holder).saphoTruthOrderChoices.some(choice => choice.card === SAPHO && choice.mode === 'first')); const pending = applyAction(f.game, f.holder, {type:'saphoTruthOrder', event:frame.event, revision:frame.revision, card:SAPHO, mode:'first'}); assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'saphoTruthOrderDiscard'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(pending.truthtrance, null); const event = pending.pendingTreacheryDiscard!.batch.event; for (const difficulty of DIFFICULTIES) { const view = viewGame(pending, 'richese'); view.players.find(row => row.id === 'richese')!.bot = difficulty; const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass'); assert.ok(action, difficulty); assert.doesNotThrow(() => applyAction(pending, 'richese', action), difficulty); } for (const claim of [false, true]) { let game = structuredClone(pending); if (claim) game = applyAction(game, 'richese', {type:'semutaCommit', event}); else for (const player of game.players) game = applyAction(game, player.id, {type:'semutaPass', event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = applyAction(game, 'richese', {type:'semutaSelect', event, card:SAPHO}); assert.equal(game.pendingTreacheryDiscard, null); assert.equal(seat(game, 'richese').hand.some(card => card.id === SAPHO), claim); assert.equal(seat(game, 'richese').hand.some(card => card.id === SEMUTA_DRUG_ID), !claim); game = finishTruthOrder(game); assert.equal(game.truthtrance!.queue[0].player, f.holder); assert.deepEqual(game.order, f.parent.order); assert.equal(game.turn, f.parent.turn); assert.equal(game.phase, f.parent.phase); assert.equal(game.active, f.parent.active); assert.deepEqual(game.movementRemaining, f.parent.movementRemaining); game = finishOriginalTruthQuestions(game); assert.equal(game.truthtrance, null); assert.deepEqual(semutaSkillsInventory(game), f.inventory); } });