import test from 'node:test';import assert from 'node:assert/strict'; import {applyAction,createGame,newPlayer,viewGame,normalizeAutomaticGame,type Game,type Action} from '../game/engine'; import {baseDeck} from '../game/cards';import {FACTIONS,type FactionId} from '../game/catalog';import {richeseCards} from '../game/richese-cards';import {createDukeVidal} from '../game/duke-vidal'; const card='richese-residual-poison'; function fixture(faction:FactionId='emperor',advanced=false,target:FactionId='fremen'){ const g=createGame('RESIDUAL',newPlayer('p','Card holder',faction),advanced,['choam']);g.players.push(newPlayer('q','Opponent',target)); if(!g.players.some(p=>p.faction==='richese'))g.players.push(newPlayer('r','Richese observer','richese')); g.status='playing';g.phase=6;g.turn=2;g.storm=18;g.order=g.players.map(p=>p.id);g.active='p';g.deck=baseDeck();g.richeseCache=richeseCards(); for(const p of g.players){p.hand=[];p.forces={};p.reserves=20;p.spice=10;p.traitors=[];} g.players[0].forces={'arrakeen:10':5};g.players[1].forces={'arrakeen:10':5};g.players[0].reserves=g.players[1].reserves=15; g.players[0].hand=[g.richeseCache.find(c=>c.id===card)!];g.richeseCache=g.richeseCache.filter(c=>c.id!==card); return applyAction(g,'p',{type:'chooseBattle',territory:'arrakeen',target:'q'}); } const play=(g:Game,extra:Partial={})=>applyAction(g,'p',{type:'card',card,target:'q',event:g.battle!.event,...extra}); const ready=(g:Game,id:string)=>applyAction(g,id,{type:'battlePreparationReady',event:g.battle!.preLeader!.event}); const reload=(g:Game):Game=>JSON.parse(JSON.stringify(g)); void test('all twelve ordinary holders kill exactly one available opposing leader with no spice, force or battle-resolution effects',()=>{ for(const f of FACTIONS){const g=fixture(f.id,false,f.id==='fremen'?'emperor':'fremen'),before=structuredClone(g),done=play(g); assert.deepEqual(g,before);assert.equal(done.players[1].leaders.filter(l=>l.dead).length,1);assert.equal(done.players[1].leaders.reduce((n,l)=>n+l.deaths,0),1); for(let i=0;i{ let g=fixture();const without=structuredClone(g);without.players[0].hand=[]; assert.deepEqual(viewGame(g,'q').battle!.preLeader,viewGame(without,'q').battle!.preLeader); assert.deepEqual(normalizeAutomaticGame(reload(g)),reload(g)); const plan={type:'battlePlan',dial:1,leader:g.players[1].leaders[0].id,weapon:null,defense:null}; assert.throws(()=>applyAction(g,'q',plan),/Both combatants/); g=ready(g,'p');assert.throws(()=>applyAction(g,'q',plan),/Both combatants/);assert.throws(()=>ready(g,'r')); g=ready(g,'q');assert.equal(g.battle!.preLeader!.closed,true);g=applyAction(g,'q',plan);assert.ok(g.battle!.plans.q); assert.throws(()=>play(g),/before either combatant/); }); void test('a same-territory used disc remains eligible while dead and elsewhere-used leaders and hero cards do not',()=>{ const g=fixture();const target=g.players[1];target.leaders.forEach((l,i)=>{if(i===0)l.usedAt='arrakeen';else if(i===1)l.usedAt='carthag';else{l.dead=true;l.deaths=1;}}); const hero=g.deck.find(c=>c.kind==='hero')!;g.deck=g.deck.filter(c=>c.id!==hero.id);target.hand=[hero]; const done=play(g);assert.equal(done.players[1].leaders[0].dead,true);assert.equal(done.players[1].leaders[0].usedAt,undefined);assert.equal(done.players[1].leaders[1].dead,false);assert.deepEqual(done.players[1].hand,[hero]); }); void test('normal foreign ghola control and Duke tank history are preserved without bounty or native duplication',()=>{ for(const kind of ['ghola','duke'] as const){const g=fixture('emperor',false,kind==='ghola'?'tleilaxu':'moritani');g.players[1].leaders.forEach(l=>{l.dead=true;l.deaths=1;}); let victim:string; if(kind==='ghola'){const foreign=g.players[0].leaders[0];foreign.gholaBy='q';victim=foreign.id;} else {g.dukeVidal=createDukeVidal();g.dukeVidal.controller='q';g.dukeVidal.source='moritani';g.dukeVidal.acquiredTurn=g.turn;victim=g.dukeVidal.leader.id;} const done=play(g);if(kind==='ghola'){const dead=done.players[0].leaders.find(l=>l.id===victim)!;assert.equal(dead.dead,true);assert.equal(dead.gholaBy,'q');assert.equal(dead.deaths,1);} else{assert.equal(done.dukeVidal!.leader.dead,true);assert.equal(done.dukeVidal!.leader.deaths,1);assert.equal(done.dukeVidal!.controller,null);assert.equal(done.players.some(p=>p.leaders.some(l=>l.id===victim)),false);} assert.equal(done.players[0].spice,10);assert.equal(done.players[1].spice,10); } }); void test('invalid timing, target, identity and state fail before random selection and preserve the card',t=>{ for(const problem of ['stale','outsider','target','sealed','prescienceLeader','empty','overflow','forgedVictim'] as const){ const g=fixture();const extra:Partial={};let actor='p'; if(problem==='stale')extra.event='stale';if(problem==='outsider')actor='r';if(problem==='target')extra.target='p'; if(problem==='sealed')g.battle!.plans.q={dial:1,leader:g.players[1].leaders[0].id,weapon:null,defense:null,support:0}; if(problem==='prescienceLeader')g.battle!.prescience={player:'p',field:'leader',value:g.players[1].leaders[0].id}; if(problem==='empty')g.players[1].leaders.forEach(l=>{l.dead=true;l.deaths=1;}); if(problem==='overflow')g.players[1].leaders[0].deaths=Number.MAX_SAFE_INTEGER; if(problem==='forgedVictim')extra.leader=g.players[1].leaders[0].id; const before=structuredClone(g);let calls=0;const mock=t.mock.method(crypto,'getRandomValues',()=>{calls++;throw Error('RNG must not run');}); assert.throws(()=>applyAction(g,actor,{type:'card',card,target:'q',event:g.battle!.event,...extra}),problem);assert.deepEqual(g,before);assert.equal(calls,0,problem);mock.mock.restore(); } }); void test('private availability never inspects RNG or exports a candidate pool, and a settled death cannot replay',t=>{ const g=fixture(),mock=t.mock.method(crypto,'getRandomValues',()=>{throw Error('No projection RNG');}); const view=viewGame(g,'p').residualPoison!;assert.equal(view.blocked,null);assert.deepEqual(Object.keys(view).sort(),['blocked','card','event','target']);assert.equal(viewGame(g,'q').residualPoison,null);mock.mock.restore(); const done=play(g),saved=reload(done);assert.deepEqual(normalizeAutomaticGame(saved),saved);assert.throws(()=>play(saved));assert.equal(saved.players[1].leaders.filter(l=>l.dead).length,1); });