import assert from 'node:assert/strict'; import test from 'node:test'; import {applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game} from '../game/engine'; import {takeKullCard} from './fixture-choam-kull'; import {advanceSemutaNexusTrade} from './fixture-semuta-nexus-trade'; import {beforeIxSpiceDraw, orderIxSpiceCards} from './fixture-ix-spice-cards'; const act = (g:Game,id:string,action:Action) => applyAction(g,id,action); const player = (g:Game,id:string) => { const p=g.players.find(row=>row.id===id); assert.ok(p); return p; }; const quiet = (g:Game) => !g.phaseOpening && !g.response && !g.decision && !g.truthtrance && !g.pendingTreacheryDiscard; function inventory(g:Game) { return [...g.deck,...g.discard,...(g.richeseCache??[]),...(g.richeseRemoved??[]), ...(g.auction?.cards.slice(g.auction.index + Number(g.currentAuctionSale?.origin==='normal'))??[]), ...(g.ixAuction?.cards??[]),...g.players.flatMap(p=>p.hand)].map(card=>card.id).sort(); } /** Public Basic five-faction setup, natural Worm/Nexus and real winning bid. * Only unopened Spice order, conserved forces/cards and wallet values are staged. */ function fixture() { let g=createGame('BASICGIFTEXP',newPlayer('e','Emperor','emperor'),false,['choam','ix']); for(const [id,faction] of [['g','guild'],['c','choam'],['r','richese'],['a','atreides']] as const) joinGame(g,newPlayer(id,faction,faction)); for(const p of g.players) g=act(g,p.id,{type:'ready'}); g=act(g,'e',{type:'start'}); assert.equal(g.kullPreview,true); assert.equal(g.semutaPreview,true); g=advanceSemutaNexusTrade(g,s=>s.status==='playing'); for(const p of g.players) {p.reserves+=Object.values(p.forces).reduce((a,b)=>a+b,0);p.forces={};} g=advanceSemutaNexusTrade(g,beforeIxSpiceDraw);orderIxSpiceCards(g,['land','land']); g=advanceSemutaNexusTrade(g,s=>s.turn===2&&beforeIxSpiceDraw(s));orderIxSpiceCards(g,['worm','land','land']); g=advanceSemutaNexusTrade(g,s=>!!s.nexus&&!s.spiceWindow&&quiet(s)); g=act(g,'e',{type:'alliance',target:'g'});g=act(g,'g',{type:'alliance',target:'e'}); // Stage physical holdings before Ix's Bidding opening deals the ordinary // auction pool; otherwise the singleton Kull may already be escrowed there. g=advanceSemutaNexusTrade(g,s=>s.phase===3&&s.phaseOpening?.initialize===true); for(const p of g.players) g.deck.push(...p.hand.splice(0)); const semuta=takeKullCard(g,'richese-semuta-drug');player(g,'r').hand.push(semuta); const kull=takeKullCard(g,'ix-kull-wahad');player(g,'c').hand.push(kull); const karama=takeKullCard(g,'karama');player(g,'a').hand.push(karama); player(g,'g').spice=7;player(g,'e').spice=8; g=advanceSemutaNexusTrade(g,s=>s.phase===3&&!!s.auction&&quiet(s)); while(g.auction!.active!=='g') g=act(g,g.auction!.active,{type:'passBid'}); const original=inventory(g),card=g.auction!.cards[g.auction!.index].id; g=act(g,'g',{type:'bid',amount:3,allyPayment:0}); for(let n=0;!g.pendingEmperorAuctionGift&&!g.response&&!g.decision&&n!g.response!.passed.includes(row.id)); assert.ok(p); g=act(g,p.id,{type:'passResponse'}); } return g; } function passSemuta(g:Game) { for(let n=0;g.pendingTreacheryDiscard?.reaction?.stage==='offer'&&n!reaction.passed.includes(row.id));assert.ok(p); g=act(g,p.id,{type:'semutaPass',event:pending.batch.event}); } return g; } function finishSale(g:Game,card:string) { const pending=g.pendingEmperorAuctionGift;assert.ok(pending); g=act(g,'e',{type:'emperorAuctionGiftContinue',event:pending.event}); if(g.decision?.kind==='auctionPayment') g=act(g,'g',{type:'decision',karama:false}); g=passResponse(g); assert.ok(player(g,'g').hand.some(row=>row.id===card),'The original paid lot is delivered once'); return g; } void test('Basic native expansion allied auction offers Emperor gift before the original payment',()=>{ const f=fixture(), before=structuredClone(f.g.auction), e=player(f.g,'e').spice, buyer=player(f.g,'g').spice; let g=act(f.g,'e',{type:'emperorGift',amount:2});g=passResponse(g); assert.deepEqual(g.auction,before);assert.equal(player(g,'e').spice,e-2);assert.equal(player(g,'g').spice,buyer+2); g=finishSale(g,f.card);assert.equal(player(g,'g').spice,buyer-1);assert.deepEqual(inventory(g),f.original); }); for(const allowKull of [false,true]) for(const claim of [false,true]) void test(`Basic allied auction gift cancellation retains Kull ${allowKull?'use':'decline'}, Semuta ${claim?'claim':'pass'} and original lot`,()=>{ const f=fixture(), e=player(f.g,'e').spice, buyer=player(f.g,'g').spice; let g=act(f.g,'e',{type:'emperorGift',amount:2}); g=act(g,'a',{type:'card',mode:'cancel',card:f.karama}); assert.equal(g.pendingKull?.stage,'offer'); const event=g.pendingKull!.event; g=act(g,'c',allowKull?{type:'kullDecision',event,source:'printed',card:f.kull}:{type:'kullDecision',event,decline:true}); if(allowKull) g=passResponse(g); assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer'); assert.equal(g.pendingTreacheryDiscard!.batch.entries[0].card.id,allowKull?f.kull:f.karama); const discardEvent=g.pendingTreacheryDiscard!.batch.event; if(claim) g=act(g,'r',{type:'semutaCommit',event:discardEvent}); else g=passSemuta(g); assert.equal(player(g,'r').hand.some(card=>card.id===(allowKull?f.kull:f.karama)),claim); if(g.response) g=passResponse(g); assert.ok(g.pendingEmperorAuctionGift,'Return to the same original gift opportunity'); assert.equal(player(g,'e').spice,e-(allowKull?2:0)); assert.equal(player(g,'g').spice,buyer+(allowKull?2:0)); g=finishSale(g,f.card);assert.deepEqual(inventory(g),f.original); });