import assert from 'node:assert/strict'; import test from 'node:test'; import { baseDeck } from '../game/cards'; import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; const act = (game:Game, id:string, action:Action) => applyAction(game,id,action); const seat = (game:Game, id:string) => game.players.find(player => player.id === id)!; /** The Basic Nexus/ledger, phase boundary and wallets are staged; the pair, * admission, held physical Truthtrance and every question/payment are real. */ function fixture(phase: 'bidding' | 'movement'):Game { let game=createGame('OUTSIDERFUND',newPlayer('a','Atreides','atreides'),false); game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'), newPlayer('d','Guild','guild'),newPlayer('e','Harkonnen','harkonnen')); Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,order:['a','b','c','d','e'], ready:[],storm:18,deck:baseDeck(),discard:[],response:null,decision:null,phaseOpening:null}); game.coalitionState=emptyCoalitionState('unlimited'); game=act(game,'a',{type:'alliance',target:'b'}); game=act(game,'b',{type:'alliance',target:'a'}); const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group); game=act(game,'a',{type:'coalitionPropose',groupId:group.id,entrant:'c', expectedRevision:game.coalitionState!.revision}); for(const id of ['b','c']) { const offer=game.coalitionState!.proposal!; game=act(game,id,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus, turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision}); } for(const player of game.players) { player.spice=10; player.hand=[]; player.forces={}; player.reserves=20; player.tanks=0; player.traitors=[]; player.traitorChoices=[]; player.shipped=false; player.moved=0; } seat(game,'a').spice=2; if(phase==='bidding') { Object.assign(game,{phase:2,nexus:false,active:null,ready:[]}); for(const player of game.players) game=act(game,player.id,{type:'ready'}); assert.equal(game.phase,3); assert.equal(game.auction?.active,'a'); } else Object.assign(game,{phase:5,nexus:false,active:'a',ready:[], movementRemaining:['a','b','c','d','e'],guildTimingGranted:false,guildTimingLocked:false}); const index=game.deck.findIndex(card=>card.effect==='truthtrance'); assert.ok(index>=0); seat(game,'a').hand.push(game.deck.splice(index,1)[0]); return game; } function ask(game:Game,question:Record):Game { const card=seat(game,'a').hand.find(row=>row.effect==='truthtrance'); assert.ok(card); game=act(game,'a',{type:'card',card:card.id}); while(game.truthtrance?.stage==='priority') { const actor=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(actor); game=act(game,actor.id,{type:'truthPass'}); } return act(game,'a',{type:'truthAsk',question}); } function pledge(game:Game):Game { game=act(game,'b',{type:'coalitionPledge',recipient:'a',amount:4}); return act(game,'c',{type:'coalitionPledge',recipient:'a',amount:3}); } function passResponses(game:Game):Game { for(let count=0;game.response && count<30;count++) { const actor=game.players.find(player=>!game.response!.passed.includes(player.id)); assert.ok(actor); game=act(game,actor.id,{type:'passResponse'}); } assert.equal(game.response ?? null,null); return game; } const shipment:Action={type:'ship',territory:'imperial_basin',sector:9,amount:3,elite:0,allyPayment:0, coalitionContributions:[{donor:'b',amount:3},{donor:'c',amount:2}]}; void test('an unrelated outsider bribe No leaves group shipment funding available',()=>{ let game=fixture('movement'); game=ask(game,{kind:'bribe',target:'d',turn:game.turn,recipient:'e',minimum:1}); assert.ok(viewGame(game,'d').truthBribeAnswers?.includes('no')); game=act(game,'d',{type:'truthAnswer',answer:'no'}); const promise=game.bribePromises!.find(row=>row.player==='d')!; assert.equal(promise.fulfilled ?? false,false); game=pledge(game); assert.equal(viewGame(game,'a').coalitionFunding?.incoming.length,2); game=passResponses(act(game,'a',shipment)); assert.equal(seat(game,'a').forces['imperial_basin:9'],3); assert.equal(seat(game,'d').spice,16); assert.ok(game.bribePromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released)); }); void test('an unrelated outsider whole-turn payment No leaves group shipment funding available',()=>{ let game=fixture('movement'); game=ask(game,{kind:'turnActions',target:'d',turn:game.turn, mixed:{kind:'action',action:{kind:'payment',recipient:'e',minimum:1}}}); assert.ok(viewGame(game,'d').truthTurnActionAnswers?.includes('no')); game=act(game,'d',{type:'truthAnswer',answer:'no'}); const promise=game.turnActionPromises!.find(row=>row.player==='d')!; game=pledge(game); game=passResponses(act(game,'a',shipment)); assert.equal(seat(game,'a').forces['imperial_basin:9'],3); assert.ok(game.turnActionPromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released)); }); void test('an unrelated outsider phase-bid No leaves group bid funding available',()=>{ let game=fixture('bidding'); game=ask(game,{kind:'phaseBid',target:'d',turn:game.turn,minimum:10}); assert.ok(viewGame(game,'d').truthPhaseBidAnswers?.includes('no')); game=act(game,'d',{type:'truthAnswer',answer:'no'}); const promise=game.phaseBidPromises!.find(row=>row.player==='d')!; game=pledge(game); game=act(game,'a',{type:'bid',amount:6,allyPayment:0, coalitionContributions:[{donor:'b',amount:3},{donor:'c',amount:2}]}); assert.equal(game.auction?.bidder,'a'); assert.ok(game.phaseBidPromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released)); }); void test('a group member commitment still fences the entire group funding path',()=>{ let game=fixture('movement'); game=ask(game,{kind:'bribe',target:'b',turn:game.turn,recipient:'e',minimum:1}); assert.ok(viewGame(game,'b').truthBribeAnswers?.includes('no')); game=act(game,'b',{type:'truthAnswer',answer:'no'}); assert.equal(viewGame(game,'a').coalitionFunding,null); const before=structuredClone(game); assert.throws(()=>act(game,'c',{type:'coalitionPledge',recipient:'a',amount:1})); assert.deepEqual(game,before); });