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 { nexusChoamTradeAction } from '../game/nexus-choam-trade-options'; import { nexusChoamBetrayalAction } from '../game/nexus-choam-betrayal-options'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { advanceSemutaNexusTrade, semutaNexusTradeInventory, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade'; import { finishOriginalTruthQuestions, finishTruthOrder } from './fixture-sapho-truth-order'; const SAPHO = 'richese-juice-of-sapho'; const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; const seat = (game: Game, id: string) => { const p = game.players.find(row => row.id === id); assert.ok(p); return p; }; const act = (game: Game, id: string, action: Parameters[2]) => applyAction(game,id,action); /** Genuine public lobby and setup; original unopened Spice order is staged to * avoid random unrelated first-turn events. Physical card locations are staged * later, with inventory conserved. */ function publicStart(paired = false) { let game = createGame('SEMUTANEXUSPUBLIC',newPlayer('r','Richese','richese'),false,['choam']); for (const [id, faction] of (paired ? [['a','atreides'],['e','emperor'],['c','choam']] as const : [['a','atreides'],['e','emperor'],['g','guild']] as const)) joinGame(game,newPlayer(id,faction,faction)); game = act(game,'r',{type:'moduleOption',module:'nexusCards',enabled:true}); for (const p of game.players) game = act(game,p.id,{type:'ready'}); game = act(game,'r',{type:'start'}); assert.equal(game.semutaPreview,true); game = advanceSemutaNexusTrade(game,state => state.status === 'playing'); assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null)); const inventory = semutaNexusTradeInventory(game); for (const position of [0,1]) { const index=game.spiceDeck.findIndex((card,i)=>i>=position && 'territory' in card); assert.ok(index>=position); game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]); } return {game,inventory}; } function hold(game: Game, id: string, match: (card: Game['deck'][number])=>boolean) { const owner=seat(game,id); const sources=[game.deck,game.discard,game.richeseCache!,...game.players.map(p=>p.hand).filter(hand=>hand!==owner.hand)]; const own=owner.hand.find(match); if (own) return own; const source=sources.find(zone=>zone.some(match)); assert.ok(source); const index=source.findIndex(match), card=source.splice(index,1)[0]; owner.hand.push(card); return card; } for (const claim of [false,true]) void test(`Basic public Nexus CHOAM Secret Ally discard offers Semuta ${claim?'claim':'pass'} and continues`,()=>{ const f=publicStart(); let game=f.game; // Return starting forces to their original reserves to avoid unrelated battles. for (const p of game.players) {p.reserves+=Object.values(p.forces).reduce((sum,n)=>sum+n,0);p.forces={};} game=advanceSemutaNexusTrade(game,state=>state.turn===2 && state.phase===1 && clean(state)); const worm=game.spiceDeck.findIndex(card=>'worm' in card&&!card.greatMaker&&!card.suppressed); assert.ok(worm>=0); game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]); game=advanceSemutaNexusTrade(game,state=>state.nexus&&!state.spiceWindow&&clean(state)); game=act(game,'e',{type:'alliance',target:'g'});game=act(game,'g',{type:'alliance',target:'e'}); game=advanceSemutaNexusTrade(game,state=>state.nexusCards?.phase?.stage==='drawing'); assert.ok(game.nexusCards!.phase!.eligible.includes('a')); game=act(game,'a',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0}); assert.ok(game.nexusCards!.cards!.hands.a); swapSemutaNexusTradeIdentity(game,'a','choam'); game=advanceSemutaNexusTrade(game,state=>state.phase===7&&clean(state)); for (const id of ['r','a']) game.deck.push(...seat(game,id).hand.splice(0)); const semuta=hold(game,'r',card=>card.id===SEMUTA_DRUG_ID); const cost=hold(game,'a',card=>card.kind==='worthless'); assert.deepEqual(semutaNexusTradeInventory(game),f.inventory); const action=nexusChoamTradeAction(viewGame(game,'a'),cost.id);assert.ok(action); const before=seat(game,'a').spice; game=act(game,'a',action); assert.equal(seat(game,'a').spice,before+2); assert.equal(game.nexusCards!.cards!.hands.a,null); assert.equal(game.pendingTreacheryDiscard?.continuation.kind,'nexusChoamTrade'); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage,'offer'); const event=game.pendingTreacheryDiscard!.batch.event; for (const difficulty of DIFFICULTIES) { const view=viewGame(game,'r'); view.players.find(p=>p.id==='r')!.bot=difficulty; const choice=botActions(view).find(row=>row.type==='semutaCommit'||row.type==='semutaPass');assert.ok(choice); assert.doesNotThrow(()=>act(game,'r',choice)); } if (claim) game=act(game,'r',{type:'semutaCommit',event}); else for (const p of game.players) game=act(game,p.id,{type:'semutaPass',event}); assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.nexusChoamTrades![0].stage,'complete'); assert.equal(seat(game,'a').spice,before+2); assert.equal(seat(game,'r').hand.some(card=>card.id===cost.id),claim); assert.equal(game.discard.some(card=>card.id===semuta.id),claim); assert.deepEqual(semutaNexusTradeInventory(game),f.inventory); game=advanceSemutaNexusTrade(game,state=>state.phase===8&&clean(state)); assert.equal(game.nexusChoamTrades![0].stage,'complete'); }); void test('Basic public Nexus Sapho Truth order offers Semuta and resumes both original questions',()=>{ const f=publicStart(); let game=f.game; game=advanceSemutaNexusTrade(game,state=>state.phase===5&&clean(state)); for (const p of game.players) game.deck.push(...p.hand.splice(0)); const holders=game.order.filter(id=>id==='a'||id==='e');assert.equal(holders.length,2); const holder=holders[1],other=holders[0]; hold(game,'r',card=>card.id===SEMUTA_DRUG_ID); hold(game,holder,card=>card.id===SAPHO); const first=hold(game,holder,card=>card.effect==='truthtrance'); const second=hold(game,other,card=>card.effect==='truthtrance'); assert.deepEqual(semutaNexusTradeInventory(game),f.inventory); const parent={turn:game.turn,phase:game.phase,active:game.active,order:[...game.order],movementRemaining:structuredClone(game.movementRemaining)}; game=act(game,holder,{type:'card',card:first.id});game=act(game,other,{type:'card',card:second.id}); while(game.truthtrance?.stage==='priority') { const p=game.players.find(row=>!game.truthtrance!.passed.includes(row.id));assert.ok(p);game=act(game,p.id,{type:'truthPass'}); } assert.equal(game.truthtrance?.stage,'order');const frame=game.truthtrance!.order!; assert.ok(viewGame(game,holder).saphoTruthOrderChoices.some(choice=>choice.card===SAPHO&&choice.mode==='first')); const pending=act(game,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 claim of [false,true]) { let next=structuredClone(pending); if (claim) next=act(next,'r',{type:'semutaCommit',event}); else for (const p of next.players) next=act(next,p.id,{type:'semutaPass',event}); assert.equal(next.pendingTreacheryDiscard,null); assert.equal(seat(next,'r').hand.some(card=>card.id===SAPHO),claim); next=finishTruthOrder(next); assert.equal(next.truthtrance!.queue[0].player,holder); assert.deepEqual(next.order,parent.order);assert.equal(next.turn,parent.turn);assert.equal(next.phase,parent.phase); assert.equal(next.active,parent.active);assert.deepEqual(next.movementRemaining,parent.movementRemaining); next=finishOriginalTruthQuestions(next); assert.equal(next.truthtrance,null);assert.deepEqual(semutaNexusTradeInventory(next),f.inventory); } }); for (const claim of [false,true]) void test(`Basic public paired CHOAM Nexus Betrayal offers Semuta ${claim?'claim':'pass'} and retires once`,()=>{ const f=publicStart(true); let game=f.game; for (const p of game.players) {p.reserves+=Object.values(p.forces).reduce((sum,n)=>sum+n,0);p.forces={};} game=advanceSemutaNexusTrade(game,state=>state.turn===2&&state.phase===1&&clean(state)); const worm=game.spiceDeck.findIndex(card=>'worm' in card&&!card.greatMaker&&!card.suppressed);assert.ok(worm>=0); game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]); game=advanceSemutaNexusTrade(game,state=>state.nexus&&!state.spiceWindow&&clean(state)); game=act(game,'r',{type:'alliance',target:'c'});game=act(game,'c',{type:'alliance',target:'r'}); game=advanceSemutaNexusTrade(game,state=>state.nexusCards?.phase?.stage==='drawing'); assert.ok(game.nexusCards!.phase!.eligible.includes('a')); game=act(game,'a',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0}); assert.ok(game.nexusCards!.cards!.hands.a);swapSemutaNexusTradeIdentity(game,'a','choam'); game=advanceSemutaNexusTrade(game,state=>state.phase===2&&clean(state)); for(const p of game.players) game.deck.push(...p.hand.splice(0)); hold(game,'r',card=>card.id===SEMUTA_DRUG_ID); const target=hold(game,'c',card=>card.kind==='worthless'); assert.deepEqual(semutaNexusTradeInventory(game),f.inventory); const action=nexusChoamBetrayalAction(viewGame(game,'a'));assert.ok(action); const wallet=seat(game,'c').spice; game=act(game,'a',action); assert.equal(game.nexusCards!.cards!.hands.a,null); assert.equal(game.pendingTreacheryDiscard?.continuation.kind,'nexusChoamBetrayal'); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage,'offer'); assert.equal(game.pendingTreacheryDiscard?.batch.entries[0].card.id,target.id); const event=game.pendingTreacheryDiscard!.batch.event; if(claim) game=act(game,'r',{type:'semutaCommit',event}); else for(const p of game.players) game=act(game,p.id,{type:'semutaPass',event}); assert.equal(game.pendingTreacheryDiscard,null); assert.equal(seat(game,'r').hand.some(card=>card.id===target.id),claim); assert.equal(seat(game,'c').spice,wallet); assert.equal(game.nexusCards!.cards!.discard.filter(card=>card==='choam').length,1); assert.deepEqual(semutaNexusTradeInventory(game),f.inventory); });