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 {SEMUTA_DRUG_ID} from '../game/semuta-drug'; import {advanceSemutaNexusTrade, swapSemutaNexusTradeIdentity} from './fixture-semuta-nexus-trade'; import {beforeIxSpiceDraw, orderIxSpiceCards} from './fixture-ix-spice-cards'; const act=(game:Game, actor:string, action:Action)=>applyAction(game,actor,action); const seat=(game:Game,id:string)=>{const player=game.players.find(row=>row.id===id);assert.ok(player);return player;}; const clean=(game:Game)=>!game.response&&!game.decision&&!game.phaseOpening&&!game.truthtrance&&!game.pendingTreacheryDiscard; function inventory(game:Game):string[] { const cards=[...game.deck,...game.discard,...(game.richeseCache??[]),...(game.richeseRemoved??[]), ...(game.auction?.cards.slice(game.auction.index+Number(game.currentAuctionSale?.origin==='normal'))??[]), ...(game.ixAuction?.cards??[]),...game.players.flatMap(player=>player.hand)].map(card=>card.id).sort(); assert.equal(new Set(cards).size,cards.length);return cards; } function acquire(game:Game, owner:string, predicate:(card:Game['deck'][number])=>boolean) { const player=seat(game,owner), held=player.hand.find(predicate);if(held)return held; const sources=[game.deck,game.discard,game.richeseCache!,...game.players.filter(row=>row.id!==owner).map(row=>row.hand)]; const source=sources.find(zone=>zone.some(predicate));assert.ok(source); const index=source.findIndex(predicate),card=source[index], displaced=player.hand.pop(); source.splice(index,1,...(displaced?[displaced]:[]));player.hand.push(card);return card; } /** A public Basic lobby earns its Nexus draw through a real worm and alliance. * Only unopened Spice order, force locations, wallets and physical cards are staged. */ function fixture() { let game=createGame('BASICNEXUSGIFT',newPlayer('c','CHOAM','choam'),false,['choam','ix']); for(const [id,faction] of [['r','richese'],['e','emperor'],['h','harkonnen'],['a','atreides']] as const) joinGame(game,newPlayer(id,faction,faction)); game=act(game,'c',{type:'moduleOption',module:'nexusCards',enabled:true}); for(const player of game.players) game=act(game,player.id,{type:'ready'}); game=act(game,'c',{type:'start'});assert.equal(game.semutaPreview,true);assert.equal(game.kullPreview,true); game=advanceSemutaNexusTrade(game,state=>state.status==='playing'); assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card=>card===null)); const original=inventory(game); for(const player of game.players) {player.reserves+=Object.values(player.forces).reduce((a,b)=>a+b,0);player.forces={};} game=advanceSemutaNexusTrade(game,beforeIxSpiceDraw);orderIxSpiceCards(game,['land','land']); game=advanceSemutaNexusTrade(game,state=>state.turn===2&&beforeIxSpiceDraw(state)); orderIxSpiceCards(game,['worm','land','land']); game=advanceSemutaNexusTrade(game,state=>!!state.nexus&&!state.spiceWindow&&clean(state)); game=act(game,'e',{type:'alliance',target:'h'});game=act(game,'h',{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}); swapSemutaNexusTradeIdentity(game,'a','emperor'); // Stage physical cards before the original auction draws its lot, so none of // these required cards can be randomly sealed in that lot. const counter=acquire(game,'a',card=>card.effect==='karama'); const buyerKarama=acquire(game,'h',card=>card.effect==='karama'&&card.id!==counter.id); const semuta=acquire(game,'r',card=>card.id===SEMUTA_DRUG_ID); game=advanceSemutaNexusTrade(game,state=>state.phase===3&&!!state.auction&&!state.richeseAuction&&clean(state)); while(game.auction!.active!=='h')game=act(game,game.auction!.active,{type:'passBid'}); seat(game,'e').spice=8;seat(game,'h').spice=4; assert.deepEqual(inventory(game),original); game=act(game,'e',{type:'pledgeAid',amount:2}); const lot=game.auction!.cards[game.auction!.index].id; game=act(game,'h',{type:'bid',amount:6,allyPayment:2}); for(let n=0;game.auction&&!game.response&&!game.decision&&n<15;n++)game=act(game,game.auction.active,{type:'passBid'}); assert.equal(game.decision?.kind,'auctionPayment'); assert.ok(game.pendingNexusEmperorPayment==null,'Gift precedes the Nexus funding receipt'); const offer=viewGame(game,'e').emperorAuctionGift;assert.ok(offer); return {game,original,counter:counter.id,buyerKarama:buyerKarama.id,semuta:semuta.id,lot,event:offer.event}; } function passResponse(game:Game) { for(let n=0;game.response&&n<20;n++) {const p=game.players.find(row=>!game.response!.passed.includes(row.id));assert.ok(p);game=act(game,p.id,{type:'passResponse'});} return game; } for(const mode of ['allow-use','allow-pass','cancel-claim-use','cancel-pass-pass'] as const) void test(`Basic public Nexus auction gift ${mode} resumes original sale`,()=>{ const f=fixture(),cancel=mode.startsWith('cancel'),use=mode.endsWith('use'); let game=act(f.game,'e',{type:'emperorGift',amount:2}); if(cancel) { game=act(game,'a',{type:'card',mode:'cancel',card:f.counter}); assert.ok(game.pendingKull,'CHOAM may interrupt the actual Karama'); game=act(game,'c',{type:'kullDecision',event:game.pendingKull.event,decline:true}); assert.equal(game.pendingTreacheryDiscard?.reaction?.stage,'offer'); const event=game.pendingTreacheryDiscard!.batch.event; if(mode==='cancel-claim-use')game=act(game,'r',{type:'semutaCommit',event}); else for(const player of game.players)game=act(game,player.id,{type:'semutaPass',event}); assert.equal(seat(game,'r').hand.some(card=>card.id===f.counter),mode==='cancel-claim-use'); assert.equal(game.discard.some(card=>card.id===f.semuta),mode==='cancel-claim-use'); } else game=passResponse(game); assert.equal(viewGame(game,'e').emperorAuctionGift!.event,f.event); assert.equal(seat(game,'h').spice,cancel?4:6);assert.equal(seat(game,'e').spice,cancel?6:4); for(const difficulty of DIFFICULTIES) { const view=viewGame(game,'e');view.players.find(p=>p.id==='e')!.bot=difficulty; const action=botActions(view).find(row=>row.type==='emperorAuctionGiftContinue');assert.ok(action); assert.doesNotThrow(()=>act(game,'e',action)); } game=act(game,'e',{type:'emperorAuctionGiftContinue',event:f.event}); assert.equal(game.decision?.kind,'nexusEmperorPayment'); while(game.decision?.kind==='nexusEmperorPayment') { const actor=game.decision.player, offer=viewGame(game,actor).nexusEmperorPayment;assert.ok(offer); game=act(game,actor,{type:'nexusEmperorPayment',event:offer.event,use:use&&actor==='a'}); } const receipt=game.nexusEmperorPayment!.completed.at(-1)!; assert.equal(receipt.stage,use?'used':'passed'); assert.equal(game.nexusCards!.cards!.hands.a,use?null:'emperor'); if(!use) { assert.equal(game.decision?.kind,'auctionPayment'); assert.equal(game.decision.player,'h'); assert.equal(seat(game,'h').spice,cancel?4:6); assert.ok(seat(game,'h').hand.some(card=>card.id===f.buyerKarama)); game=act(game,'h',{type:'decision',karama:false}); } assert.equal(seat(game,'h').spice,use?(cancel?4:6):(cancel?0:2)); assert.equal(seat(game,'e').spice,use?(cancel?2:0):(cancel?6:4)); assert.equal(game.response?.kind,'emperorIncome'); assert.equal(game.currentAuctionSale?.winner,'h');assert.equal(game.currentAuctionSale?.amount,6); assert.equal(seat(game,'h').hand.filter(card=>card.id===f.lot).length,1); game=passResponse(game); assert.equal(seat(game,'e').spice,use?(cancel?8:6):(cancel?12:10)); assert.deepEqual(inventory(game),f.original); });