import assert from 'node:assert/strict'; import test from 'node:test'; import { registerHooks } from 'node:module'; import { readFileSync } from 'node:fs'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { baseDeck } from '../game/cards'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { strongholdProgress } from '../game/victory-progress'; import { quoteVictory } from '../game/victory-quote'; import { createVictoryContinuation, voteVictoryContinuation } from '../game/victory-continuation'; const aliases = registerHooks({ resolve(specifier, context, next) { return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); }, load(url, context, next) { if (!url.endsWith('.module.css')) return next(url, context); const css = readFileSync(new URL(url),'utf8'); const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1],match[1]])); return {format:'module',source:`export default ${JSON.stringify(classes)}`,shortCircuit:true}; }, }); const {GameTable} = await import('../components/game-table'); aliases.deregister(); const send = (game: Game, id: string, action: Action) => applyAction(game,id,action); const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!; /** Staged Basic Nexus and unexposed unlimited ledger; all pair and group * negotiations are real actions. The board and later phase boundaries are * deliberately staged in their respective tests. */ function pair(variant: 'unlimited' | 'legacy' = 'unlimited'): Game { let game = createGame('UNLIMITED',newPlayer('a','Atreides','atreides'),false); game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'), newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild')); Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,storm:18, order:['a','b','c','d','e'],ready:[],deck:baseDeck()}); game.coalitionState = emptyCoalitionState(variant === 'unlimited' ? variant : undefined); game = send(game,'a',{type:'alliance',target:'b'}); return send(game,'b',{type:'alliance',target:'a'}); } function propose(game:Game, entrant:string):Game { const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group); return send(game,'a',{type:'coalitionPropose',groupId:group.id,entrant, expectedRevision:game.coalitionState!.revision}); } function answer(game:Game, id:string, accept=true):Game { const offer=game.coalitionState!.proposal; assert.ok(offer); return send(game,id,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus, turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision}); } function add(game:Game,entrant:string):Game { game=propose(game,entrant); const voters=[...game.coalitionState!.proposal!.original.members.filter(id=>id!=='a'),entrant]; for(const id of voters) game=answer(game,id); return game; } function four():Game { return add(add(pair(),'c'),'d'); } function movement(game:Game):Game { Object.assign(game,{phase:5,nexus:false,active:'a',ready:[],movementRemaining:['a','b','c','d','e'], guildTimingGranted:false,guildTimingLocked:false}); for(const p of game.players) { p.forces={}; p.reserves=20; p.tanks=0; p.spice=10; p.hand=[]; p.traitors=[]; p.traitorChoices=[]; p.shipped=false; p.moved=0; } return game; } function pledge(game:Game,id:string,amount:number):Game { return send(game,id,{type:'coalitionPledge',recipient:'a',amount}); } function remaining(game:Game,donor:string):number { return game.coalitionEscrow!.pledges.find(row=>row.donor===donor && row.recipient==='a')?.amount ?? 0; } function passResponses(game:Game):Game { for(let count=0;game.response && count<20;count++) { const actor=game.players.find(p=>!game.response!.passed.includes(p.id)); assert.ok(actor); game=send(game,actor.id,{type:'passResponse'}); } assert.equal(game.response ?? null,null); return game; } void test('four-person unlimited group needs every approval and bots choose legal answers',()=>{ let game=add(pair(),'c'); game=propose(game,'d'); const offer=game.coalitionState!.proposal!; assert.deepEqual(offer.original.members,['a','b','c']); for(const difficulty of DIFFICULTIES) for(const id of ['b','c','d']) { const view=viewGame(game,id); view.players.find(p=>p.id===id)!.bot=difficulty; const action=botActions(view)[0]; assert.ok(action); assert.equal(action.type,'coalitionAnswer'); assert.equal(action.accept,true); const result=send(game,id,action); assert.ok(result.coalitionState!.proposal?.approvals.includes(id)); } game=answer(game,'b'); game=answer(game,'c'); assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']); game=answer(game,'d'); const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group); assert.deepEqual(group.members,['a','b','c','d']); assert.equal(group.edges.length,6); assert.equal(game.coalitionState!.proposal,null); assert.ok(group.members.every(id=>seat(game,id).ally===null)); }); void test('each bot difficulty can request admission to an existing four-person alliance',()=>{ const game=four(); const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group); for(const difficulty of DIFFICULTIES) { const view=viewGame(game,'e'); view.players.find(player=>player.id==='e')!.bot=difficulty; const action=botActions(view)[0]; assert.ok(action); assert.deepEqual(action,{type:'coalitionPropose',groupId:group.id,entrant:'e', expectedRevision:game.coalitionState!.revision}); let pending=send(game,'e',action); assert.equal(pending.coalitionState!.proposal?.proposer,'e'); assert.deepEqual(pending.coalitionState!.proposal?.approvals,['e']); for(const id of ['a','b','c','d']) pending=answer(pending,id); assert.deepEqual(coalitionOf(pending.players,pending.coalitionState,'e')!.members,['a','b','c','d','e']); } }); void test('actual table shows four allies and fifth-player invitation to all participants',()=>{ const game=propose(four(),'e'); const offer=game.coalitionState!.proposal; assert.ok(offer); assert.deepEqual(offer.original.members,['a','b','c','d']); for(const id of ['a','b','c','d','e']) { const view=viewGame(game,id); const markup=renderToStaticMarkup(createElement(GameTable,{game:view,busy:false,send:async()=>{},onExit(){}})); assert.match(markup,/Allied with (?:)?Emperor and Fremen and Harkonnen/); assert.match(markup,/Waiting for:/); assert.match(markup,/Guild/); if(id!=='a') assert.match(markup,/Accept invitation/); else assert.match(markup,/Withdraw and cancel invitation/); } }); void test('three named allies fund one physical shipment once',()=>{ let game=movement(four()); seat(game,'a').spice=3; for(const donor of ['b','c','d']) game=pledge(game,donor,2); assert.equal(viewGame(game,'a').coalitionFunding?.incoming.length,3); game=send(game,'a',{type:'ship',territory:'imperial_basin',sector:9,amount:3,elite:0, allyPayment:0,coalitionContributions:['b','c','d'].map(donor=>({donor,amount:1}))}); game=passResponses(game); assert.equal(seat(game,'a').forces['imperial_basin:9'],3); assert.equal(seat(game,'a').reserves,17); assert.equal(seat(game,'a').spice,0); assert.equal(seat(game,'e').spice,16); for(const donor of ['b','c','d']) {assert.equal(seat(game,donor).spice,8); assert.equal(remaining(game,donor),1);} }); void test('departure retains group and surviving escrow edges, then restores a pair and allows rejoining',()=>{ let game=movement(four()); game=pledge(game,'b',2); game=pledge(game,'c',3); game=pledge(game,'d',4); // Preserve the actual escrow while staging the next Nexus; no phase-end // refunds are simulated, so departing-edge refunds are directly observable. Object.assign(game,{phase:1,nexus:true,active:null,ready:[]}); const epoch=game.coalitionEscrow!.authorizations.find(row=>row.members.join('')==='ab')!.epoch; game=send(game,'d',{type:'coalitionLeave',expectedRevision:game.coalitionState!.revision}); assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']); assert.equal(seat(game,'d').spice,10); assert.equal(remaining(game,'d'),0); assert.equal(remaining(game,'b'),2); assert.equal(remaining(game,'c'),3); game=send(game,'c',{type:'coalitionLeave',expectedRevision:game.coalitionState!.revision}); assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b']); assert.equal(seat(game,'a').ally,'b'); assert.equal(seat(game,'b').ally,'a'); assert.equal(seat(game,'c').spice,10); assert.equal(remaining(game,'c'),0); assert.equal(remaining(game,'b'),2); assert.equal(game.coalitionEscrow!.authorizations.find(row=>row.members.join('')==='ab')!.epoch,epoch); game=add(game,'d'); assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','d']); assert.equal(remaining(game,'b'),2); assert.equal(remaining(game,'d'),0); }); void test('unlimited alliance requires three distinct strongholds while the existing staged group retains four',()=>{ const game=four(); game.phase=8; game.nexus=false; game.storm=18; for(const player of game.players) player.forces={}; seat(game,'a').forces={'arrakeen:10':1}; seat(game,'b').forces={'carthag:11':1}; seat(game,'c').forces={'sietch_tabr:14':1}; const row=strongholdProgress(game).progress.find(progress=>progress.player==='a'); assert.ok(row); assert.equal(row.target,3); assert.equal(row.qualifies,true); assert.deepEqual(quoteVictory(game).winner,['a','b','c','d']); const old=add(pair('legacy'),'c'); old.phase=8; old.nexus=false; old.storm=18; for(const player of old.players) player.forces={}; seat(old,'a').forces={'arrakeen:10':1}; seat(old,'b').forces={'carthag:11':1}; seat(old,'c').forces={'sietch_tabr:14':1}; const original=strongholdProgress(old).progress.find(progress=>progress.player==='a'); assert.ok(original); assert.equal(original.target,4); assert.equal(original.qualifies,false); }); void test('unlimited variant also gives a pair the three-stronghold target',()=>{ const game=pair(); game.phase=8; game.nexus=false; game.storm=18; for(const player of game.players) player.forces={}; seat(game,'a').forces={'arrakeen:10':1}; seat(game,'b').forces={'carthag:11':1,'sietch_tabr:14':1}; const row=strongholdProgress(game).progress.find(progress=>progress.player==='a'); assert.ok(row); assert.equal(row.target,3); assert.equal(row.qualifies,true); assert.deepEqual(quoteVictory(game).winner,['a','b']); }); void test('victory continuation accepts a four-player side and a distinct qualifying side',()=>{ // Public continuation is exercised with two synthetic already-qualifying // sides; actual four-member victory is independently quoted above. let frame=createVictoryContinuation({event:'continue-four',turn:2,resume:'mentat', order:['a','b','c','d','e'],sides:[['a','b','c','d'],['e']]}); assert.deepEqual(frame.sides,[['a','b','c','d'],['e']]); assert.deepEqual(frame.voters,['a','b','c','d','e']); for(const [index,id] of frame.voters.entries()) { const result=voteVictoryContinuation(frame,{event:'continue-four',player:id,choice:'continue', ...(index===0 ? {extraStrongholds:1} : {})}); if(result.outcome==='pending') frame=result.frame; else {assert.equal(index,4); assert.deepEqual(result,{outcome:'continue',extraStrongholds:1});} } });