import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, joinGame, newPlayer, 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 { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech'; import { semutaSkillsInventory } from './fixture-semuta-skills'; import { finishOriginalTruthQuestions, finishTruthOrder } from './fixture-sapho-truth-order'; const SAPHO = 'richese-juice-of-sapho'; 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; }; function step(game: Game) { if (!game.phaseOpening && !game.response && !game.decision && game.phase === 2 && game.charityOrder) { const actor = game.charityOrder.remaining[0]; assert.ok(actor); return {actor, action:{type:'ready'}}; } return nextRicheseSkillsTechNativeStep(game); } /** Public CHOAM/Richese lobby, original setup and native Movement. Only the * order of two unopened ordinary Spice Cards and actual card locations are * staged. Every held card returns to its original deck before selecting four * existing physical cards, preserving the complete inventory. */ function fixture(module: 'leaderSkills' | 'discoveries', advanced: boolean) { let game = createGame('SEMUTATRUTHPUBLIC', newPlayer('r','Richese','richese'), advanced, ['choam']); joinGame(game, newPlayer('a','Atreides','atreides')); joinGame(game, newPlayer('e','Emperor','emperor')); game = applyAction(game, game.host, {type:'moduleOption',module,enabled:true}); for (const p of game.players) game = applyAction(game,p.id,{type:'ready'}); game = applyAction(game,game.host,{type:'start',...(advanced ? {advancedPreview:true} : {})}); assert.equal(game.semutaPreview,true); for (let n=0; game.status==='setup' && n<300; n++) { if (game.setupStage==='leaderSkills') { const actor=Object.keys(game.leaderSkills!.offers)[0]; const offer=game.leaderSkills!.offers[actor], own=viewGame(game,actor).leaderSkills!; const skill=offer.cards.find(id=>!own.unavailableSkills?.[id]); const leader=own.eligibleLeaders[0]; assert.ok(skill && leader); game=applyAction(game,actor,{type:'leaderSkill',event:offer.event,skill,leader:leader.id}); } else { const next=step(game); game=applyAction(game,next.actor,next.action); } } assert.equal(game.status,'playing'); for (const position of [0,1]) { const index=game.spiceDeck.findIndex((card,i)=>i>=position && 'territory' in card && !card.discovery); assert.ok(index>=position); game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]); } for (let n=0;n<1600 && !(game.phase===5 && !game.phaseOpening && !game.response && !game.decision);n++) { assert.equal(game.turn,1); const next=step(game); game=applyAction(game,next.actor,next.action); } assert.equal(game.phase,5); assert.ok(!game.phaseOpening && !game.response && !game.decision); const inventory=semutaSkillsInventory(game); for (const p of game.players) game.deck.push(...p.hand.splice(0)); const give=(id:string,identity:string) => { const zones=[game.deck,game.discard,game.richeseCache!,...game.players.map(p=>p.hand)]; const source=zones.find(zone=>zone.some(card=>card.id===identity || card.effect===identity)); assert.ok(source,`Missing original ${identity}`); const index=source.findIndex(card=>card.id===identity || card.effect===identity); const [card]=source.splice(index,1); seat(game,id).hand.push(card); return card.id; }; const others=game.order.filter(id=>id==='a' || id==='e'); assert.equal(others.length,2); const holder=others[1], other=others[0]; give('r',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(p=>!game.truthtrance!.passed.includes(p.id)); assert.ok(actor); game=applyAction(game,actor.id,{type:'truthPass'}); } assert.equal(game.truthtrance?.stage,'order'); return {game,holder,other,truth,otherTruth,inventory,parent}; } for (const selectedModule of ['leaderSkills','discoveries'] as const) for (const advanced of [false,true]) void test(`${advanced?'Advanced':'Basic'} public ${selectedModule} preserves Sapho Truth-order through Semuta`,()=>{ const f=fixture(selectedModule,advanced); const frame=f.game.truthtrance!.order!; assert.ok(viewGame(f.game,f.holder).saphoTruthOrderChoices.some(choice=>choice.card===SAPHO && choice.mode==='first')); const action={type:'saphoTruthOrder',event:frame.event,revision:frame.revision,card:SAPHO,mode:'first'}; const pending=applyAction(copy(f.game),f.holder,action); 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 level of DIFFICULTIES) { const own=viewGame(pending,'r'); own.players.find(p=>p.id==='r')!.bot=level; const bot=botActions(own).find(row=>row.type==='semutaCommit' || row.type==='semutaPass'); assert.ok(bot,`${level} can answer this neutral reaction`); assert.doesNotThrow(()=>applyAction(copy(pending),'r',bot)); } for (const claim of [false,true]) { let game=copy(pending); if (claim) game=applyAction(game,'r',{type:'semutaCommit',event}); else for (const p of game.players) game=applyAction(game,p.id,{type:'semutaPass',event}); assert.equal(game.pendingTreacheryDiscard,null); assert.equal(seat(game,'r').hand.some(card=>card.id===SAPHO),claim); assert.equal(seat(game,'r').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); } });