Files
Dune/tests/nullentropy-box-engine.test.ts
T

72 lines
5.5 KiB
TypeScript

import test from 'node:test';import assert from 'node:assert/strict';
import {applyAction,createGame,newPlayer,viewGame,normalizeAutomaticGame,type Game} from '../game/engine';
import {baseDeck} from '../game/cards';import {FACTIONS,type FactionId} from '../game/catalog';import {richeseCards} from '../game/richese-cards';
const boxId='richese-nullentropy-box';
function fixture(faction:FactionId='richese',advanced=false,phase=4){
const g=createGame('BOXENGINE',newPlayer('p','Box owner',faction),advanced,['choam']);
const other=FACTIONS.find(f=>f.id!==faction)!;g.players.push(newPlayer('q','Other',other.id));
g.status='playing';g.phase=phase;g.turn=2;g.order=['p','q'];g.active='q';g.deck=baseDeck();g.richeseCache=richeseCards();
for(const p of g.players){p.hand=[];p.spice=10;p.forces={};}
const box=g.richeseCache.find(c=>c.id===boxId)!;g.richeseCache=g.richeseCache.filter(c=>c.id!==box.id);g.players[0].hand=[box];
g.discard=g.deck.splice(0,3);return g;
}
const begin=(g:Game)=>applyAction(g,'p',{type:'card',card:boxId});
const choose=(g:Game,id:string)=>applyAction(g,'p',{type:'decision',event:g.pendingNullentropy!.event,card:id});
const reload=(g:Game):Game=>JSON.parse(JSON.stringify(g));
void test('all twelve holders can pay and search across nine phases in both modes without changing non-Box resources',()=>{
for(const f of FACTIONS)for(const advanced of [false,true])for(let phase=0;phase<9;phase++){
const g=fixture(f.id,advanced,phase),before=structuredClone(g),selected=g.discard[0];
const paid=begin(g);assert.deepEqual(g,before);assert.equal(paid.players[0].spice,8);assert.deepEqual(paid.discard,g.discard);
assert.equal(paid.decision?.kind,'nullentropy');assert.deepEqual(normalizeAutomaticGame(reload(paid)),reload(paid));
const done=choose(reload(paid),selected.id);assert.deepEqual(done.players[0].hand,[selected]);assert.equal(done.players[0].spice,8);
assert.equal(done.players[1].spice,10);assert.deepEqual(done.deck,g.deck);assert.deepEqual(done.richeseCache,g.richeseCache);
assert.equal(done.discard.at(-1)!.id,boxId);assert.equal(done.discard.length,g.discard.length);assert.equal(done.phase,phase);
assert.equal(done.pendingNullentropy,null);assert.throws(()=>begin(done));
}
});
void test('prepayment projection has no candidates and selection entitlement ends after exact private recovery',()=>{
const g=fixture(), selected=g.discard[0];
const unpaid=viewGame(g,'p').nullentropy!;assert.equal(unpaid.search,null);
assert.equal(viewGame(g,'q').nullentropy,null);
const paid=begin(g);assert.equal(viewGame(paid,'p').nullentropy!.search!.cards.length,3);
assert.equal(viewGame(paid,'q').nullentropy,null);assert.equal(JSON.stringify(viewGame(paid,'q')).includes(selected.id),false);
const done=choose(paid,selected.id);assert.equal(viewGame(done,'p').nullentropy,null);
assert.equal(JSON.stringify(viewGame(done,'q')).includes(selected.id),false);assert.equal(done.log.some(l=>l.text.includes(selected.name)),false);
});
void test('a sole eligible card is taken automatically while every other Box stays in the shuffled pile below the used Box',()=>{
const g=fixture(),selected=g.discard[0];g.discard=[selected,{...g.players[0].hand[0],id:'other-box'}];
const done=begin(g);assert.equal(done.pendingNullentropy,null);assert.equal(done.decision,null);assert.equal(done.players[0].spice,8);
assert.deepEqual(done.players[0].hand,[selected]);assert.deepEqual(done.discard.map(c=>c.id),['other-box',boxId]);
});
void test('illegal starts do not charge or expose a search, including Guild-refund cases',()=>{
for(const change of ['funds','empty','onlyBox','reservedRefund','preselect'] as const){
const g=fixture();
if(change==='funds')g.players[0].spice=1;if(change==='empty')g.discard=[];
if(change==='onlyBox')g.discard=[{...g.players[0].hand[0],id:'other-box'}];
if(change==='reservedRefund'){g.players[1].faction='guild';g.karamaShipping={owner:'p',player:'p',card:g.discard[0].id};}
const before=structuredClone(g);
assert.throws(()=>applyAction(g,'p',{type:'card',card:boxId,...(change==='preselect'?{give:g.discard[0].id}:{})}));
assert.deepEqual(g,before);assert.equal(viewGame(g,'p').nullentropy!.search,null);
}
});
void test('a paid auction bid is not charged again as a reservation when Box is played during its remaining income response',()=>{
const g=fixture('richese',true,3);g.players[0].spice=2;
const purchased=g.deck.shift()!;g.players[0].hand.push(purchased);
g.auction={cards:[purchased],index:0,bid:5,bidder:'p',active:'p',passed:[],opener:0};
g.currentAuctionSale={winner:'p',amount:5,free:false,origin:'normal',seller:null};
g.response={kind:'emperorIncome',owner:'q',passed:[]};
const counter=g.deck.splice(g.deck.findIndex(c=>c.effect==='karama'),1)[0];g.players[0].hand.push(counter);
const paid=begin(g);assert.equal(paid.players[0].spice,0);assert.deepEqual(paid.pendingNullentropy!.resume.response,g.response);
const done=choose(paid,g.discard[0].id);assert.deepEqual(done.response,g.response);assert.deepEqual(done.currentAuctionSale,g.currentAuctionSale);
});
void test('invalid selections preserve the paid search and reject before any shuffle',t=>{
const paid=begin(fixture());const before=structuredClone(paid);let rng=0;
t.mock.method(crypto,'getRandomValues',()=>{rng++;throw Error('Unexpected RNG');});
for(const action of [
{type:'decision',event:'stale',card:paid.discard[0].id},
{type:'decision',event:paid.pendingNullentropy!.event,card:'not-in-discard'},
{type:'card',card:boxId},
]){assert.throws(()=>applyAction(paid,'p',action));assert.deepEqual(paid,before);}
assert.equal(rng,0);
});