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 {botActions} from '../game/bots'; import {DIFFICULTIES} from '../game/bot-profiles'; import {takeKullCard} from './fixture-choam-kull'; import {advanceSemutaNexusTrade, nextSemutaNexusTradeNativeStep} 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) { const cards=[...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(row=>row.hand)].map(card=>card.id).sort(); assert.equal(new Set(cards).size,cards.length);return cards; } function passResponse(g:Game) { for(let n=0;g.response&&n!g.response!.passed.includes(p.id));assert.ok(seat); g=act(g,seat.id,{type:'passResponse'}); } return g; } function passSemuta(g:Game) { for(let n=0;g.pendingTreacheryDiscard?.reaction?.stage==='offer'&&n!reaction.passed.includes(p.id));assert.ok(seat); g=act(g,seat.id,{type:'semutaPass',event:pending.batch.event}); } return g; } /** Genuine public Basic setup, qualifying worm, alliance and paid cache bid. * Staged only unopened Spice, reserve positions, purses and conserved physical * hand/deck ownership before Ix's auction pool is drawn. */ function fixture(method:'onceAround'|'silent') { let g=createGame('BASICRICHGIFT',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((sum,n)=>sum+n,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'}); 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.decision?.kind==='richeseCache'); const selected=g.richeseCache!.find(card=>card.id!==semuta.id);assert.ok(selected); const original=inventory(g),card=selected.id; g=act(g,'r',{type:'decision',event:g.richeseBidding!.event,card,method, ...(method==='onceAround'?{direction:'clockwise'}:{})}); g=act(g,'e',{type:'pledgeAid',amount:1}); let bid=false; for(let n=0;!g.pendingEmperorAuctionGift&&n<300;n++) { const lot=g.richeseAuction; if(lot&&!lot.outcome&&!g.response&&!g.decision&&lot.method==='onceAround'&&lot.active) { const seat=lot.active, buy=seat==='g'&&!bid; g=act(g,seat,{type:'richeseBid',event:lot.event,amount:buy?3:null,...(buy?{allyPayment:1}:{})}); if(buy)bid=true; } else if(lot&&!lot.outcome&&!g.response&&!g.decision&&lot.method==='silent') { const seat=lot.eligible.find(id=>!Object.hasOwn(lot.sealed,id));assert.ok(seat); const buy=seat==='g'&&!bid; g=act(g,seat,{type:'richeseBid',event:lot.event,amount:buy?3:0,...(buy?{allyPayment:1}:{})}); if(buy)bid=true; } else { const next=nextSemutaNexusTradeNativeStep(g);g=act(g,next.actor,next.action); } } assert.equal(bid,true);assert.ok(g.pendingEmperorAuctionGift,'Emperor receives a prepayment opportunity'); assert.equal(g.richeseAuction?.outcome?.kind,'sold');assert.equal(g.currentAuctionSale??null,null); assert.equal(viewGame(g,'e').emperorAuctionGift?.recipient,'g'); return {g,card,kull:kull.id,karama:karama.id,original}; } function finishSale(g:Game, card:string) { const pending=g.pendingEmperorAuctionGift;assert.ok(pending); g=act(g,'e',{type:'emperorAuctionGiftContinue',event:pending.event}); g=passResponse(g); assert.ok(player(g,'g').hand.some(row=>row.id===card)); return g; } for(const method of ['onceAround','silent'] as const) for(const share of [false,true]) void test( `Basic ${method} cache sale retains committed split after Emperor ${share?'gift':'continue'}`,()=>{ const f=fixture(method),before=structuredClone(f.g.richeseAuction), funding=structuredClone(f.g.richeseFunding); const e=player(f.g,'e').spice,buyer=player(f.g,'g').spice,seller=player(f.g,'r').spice; const view=viewGame(f.g,'e');for(const level of DIFFICULTIES) { const bot=structuredClone(view);bot.players.find(row=>row.id==='e')!.bot=level; const choice=botActions(bot).find(action=>action.type==='emperorAuctionGiftContinue');assert.ok(choice); assert.doesNotThrow(()=>act(f.g,'e',choice)); } let g=f.g; if(share){g=act(g,'e',{type:'emperorGift',amount:2});g=passResponse(g);} assert.deepEqual(g.richeseAuction,before);assert.deepEqual(g.richeseFunding,funding); assert.equal(player(g,'e').spice,e-(share?2:0));assert.equal(player(g,'g').spice,buyer+(share?2:0)); g=finishSale(g,f.card); assert.equal(player(g,'g').spice,buyer+(share?2:0)-2); assert.equal(player(g,'e').spice,e-(share?2:0)); assert.equal(g.aid.e?.amount??0,0); assert.equal(player(g,'r').spice,seller+3);assert.deepEqual(inventory(g),f.original); }); for(const claim of [false,true]) void test(`Basic cache sale gift cancellation survives Kull decline and Semuta ${claim?'claim':'pass'}`,()=>{ const f=fixture('onceAround'),e=player(f.g,'e').spice,buyer=player(f.g,'g').spice; const event=f.g.pendingEmperorAuctionGift!.event; 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');g=act(g,'c',{type:'kullDecision',event:g.pendingKull!.event,decline:true}); assert.equal(g.pendingTreacheryDiscard?.reaction?.stage,'offer');const discard=g.pendingTreacheryDiscard!.batch.event; if(claim)g=act(g,'r',{type:'semutaCommit',event:discard});else g=passSemuta(g); assert.equal(player(g,'r').hand.some(card=>card.id===f.karama),claim); assert.equal(player(g,'e').spice,e);assert.equal(player(g,'g').spice,buyer); assert.equal(g.pendingEmperorAuctionGift?.event,event);g=finishSale(g,f.card); assert.equal(player(g,'e').spice,e);assert.equal(g.aid.e?.amount??0,0); assert.equal(player(g,'g').spice,buyer-2); assert.deepEqual(inventory(g),f.original); });