Files
Dune/tests/coalition-unlimited-engine.test.ts
T
Quintine 9cb65a21f5
Verify and publish checkpoint container / container (push) Canceled after 0s
Prototype unlimited alliance variant
2026-10-29 00:33:42 +11:00

214 lines
12 KiB
TypeScript

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});}
}
});