Prototype unlimited alliance variant
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 00:33:42 +11:00
1 parent 37acaeb9d2
commit 9cb65a21f5
23 files changed
+677 -52

No files matched your search

@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { CoalitionNexus, coalitionNexusOptions, type CoalitionNexusView } from '../components/coalition-nexus';
import type { CoalitionGroup } from '../game/coalition-alliance';
const players = ['a', 'b', 'c', 'd', 'e', 'f'].map(id => ({id, name: id.toUpperCase()}));
function group(members: string[]): CoalitionGroup {
const edges: CoalitionGroup['edges'] = [];
for (let left = 0; left < members.length; left++)
for (let right = left + 1; right < members.length; right++)
edges.push({members: [members[left], members[right]], formedTurn: 1});
return {id: 'group', members, edges, formedTurn: 1};
}
function view(me: string, members: string[], unlimited = true): CoalitionNexusView {
return {me, groups: [group(members)], players, proposal: null, revision: 7,
...(unlimited ? {variant: 'unlimited' as const} : {})};
}
function render(value: CoalitionNexusView, disabled = false) {
return renderToStaticMarkup(createElement(CoalitionNexus, {view: value, disabled, onIntent() {}}));
}
void test('unlimited membership offers entry only to a member or the unallied entrant', () => {
const member = view('a', ['a', 'b', 'c']);
assert.deepEqual(coalitionNexusOptions(member).invitations.map(row => row.entrant), ['d', 'e', 'f']);
assert.match(render(member), /Invite D to A, B, C/);
const entrant = view('d', ['a', 'b', 'c']);
assert.deepEqual(coalitionNexusOptions(entrant).invitations.map(row => row.entrant), ['d']);
assert.match(render(entrant), /Ask to join A, B, C/);
assert.deepEqual(coalitionNexusOptions(view('missing', ['a', 'b', 'c'])).invitations, []);
assert.deepEqual(coalitionNexusOptions(view('a', ['a', 'b', 'c'], false)).invitations, [],
'An older three-member prototype must not admit a fourth player.');
});
void test('unlimited invitations allow a sixth player but never a seventh', () => {
const five = view('a', ['a', 'b', 'c', 'd', 'e']);
assert.deepEqual(coalitionNexusOptions(five).invitations.map(row => row.entrant), ['f']);
assert.match(render(five), /Invite F to A, B, C, D, E/);
assert.match(render(five), /Leaving keeps the other players allied/);
const six = view('a', ['a', 'b', 'c', 'd', 'e', 'f']);
assert.deepEqual(coalitionNexusOptions(six).invitations, []);
assert.match(render(six), /Leave A, B, C, D, E, F/);
assert.match(render(six, true), /disabled=""[^>]*>Leave A, B, C, D, E, F/);
});
void test('every existing member and the entrant can answer one pending group invitation', () => {
const original = group(['a', 'b', 'c']);
const value = view('d', original.members);
value.proposal = {event: 'event', nexus: 'nexus', turn: 2, groupId: original.id,
original, entrant: 'd', proposer: 'a', approvals: ['a', 'b']};
assert.equal(coalitionNexusOptions(value).mayAccept, true);
assert.match(render(value), /Every current member and the invited player must agree/);
assert.match(render(value), /Approved: A, B/);
assert.match(render(value), /Waiting for: C, D/);
assert.match(render(value), /Accept invitation/);
value.me = 'b';
assert.equal(coalitionNexusOptions(value).mayAccept, false);
assert.match(render(value), /Withdraw approval and cancel invitation/);
value.me = 'e';
assert.equal(coalitionNexusOptions(value).mayAccept, false);
assert.equal(coalitionNexusOptions(value).mayDecline, false);
assert.doesNotMatch(render(value), /Accept invitation|cancel invitation/);
});
+108
View File
@@ -0,0 +1,108 @@
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 send = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
/** The lobby cannot start this variant yet. Stage a Basic Nexus; the pair,
* both admissions and every later decision are actual authoritative actions. */
function fixture(phase = 4) {
let game = createGame('UNLIMITEMP', newPlayer('e', 'Emperor', 'emperor'), false);
game.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('h', 'Harkonnen', 'harkonnen'),
newPlayer('g', 'Guild', 'guild'), newPlayer('f', 'Fremen', 'fremen'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
order:['e','a','h','g','f'], ready:[], storm:18, deck:baseDeck(),
response:null, decision:null, phaseOpening:null});
game.coalitionState = emptyCoalitionState('unlimited');
game = send(game, 'e', {type:'alliance', target:'a'});
game = send(game, 'a', {type:'alliance', target:'e'});
for (const entrant of ['h', 'g']) {
const group = coalitionOf(game.players, game.coalitionState, 'e'); assert.ok(group);
game = send(game, 'e', {type:'coalitionPropose', groupId:group.id, entrant,
expectedRevision:game.coalitionState!.revision});
for (const id of [...group.members.filter(member => member !== 'e'), entrant]) {
const proposal = game.coalitionState!.proposal!;
game = send(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
}
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'e')!.members, ['a','e','g','h']);
Object.assign(game, {phase, nexus:false, ready:[], response:null, decision:null});
for (const player of game.players) {
player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10;
player.hand = []; player.traitors = []; player.traitorChoices = [];
}
seat(game, 'e').spice = 20;
for (const id of ['a','g','h']) { seat(game,id).reserves = 16; seat(game,id).tanks = 4; }
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [karama] = game.deck.splice(index, 1); seat(game, 'f').hand.push(karama);
return {game, karama};
}
function passResponses(game: Game): Game {
for (let count = 0; game.response && count < 30; count++) {
const next = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(next);
game = send(game, next.id, {type:'passResponse'});
}
assert.equal(game.response ?? null, null);
return game;
}
function conserved(game: Game) {
for (const player of game.players)
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a,b) => a+b,0), 20);
}
void test('four-member Emperor gifts reach every selected ally through the native response', () => {
let {game} = fixture(3);
assert.deepEqual(viewGame(game, 'e').coalitionEmperor?.allies.sort(), ['a','g','h']);
for (const target of ['a','h','g']) {
const pending = send(game, 'e', {type:'emperorGift', target, amount:2});
assert.equal(pending.response?.kind, 'emperorGift');
assert.equal(pending.response?.recipient, target);
game = passResponses(pending);
assert.equal(seat(game,target).spice, 12);
}
assert.equal(seat(game,'e').spice,14);
const before = structuredClone(game);
assert.throws(() => send(game,'e',{type:'emperorGift', target:'f', amount:1}));
assert.deepEqual(game,before);
conserved(game);
});
for (const type of ['emperorGift','emperorRevival'] as const)
void test(`Karama cancels a four-member Emperor ${type} for the fourth member`, () => {
const initial = fixture();
let game = send(initial.game, 'e', {type, target:'g', amount:2, elite:0});
assert.equal(game.response?.kind, type);
assert.equal(game.response?.recipient,'g');
game = send(game,'f',{type:'card',mode:'cancel',card:initial.karama.id});
game = passResponses(game);
assert.equal(seat(game,'e').spice,20);
assert.equal(seat(game,'g').spice,10);
assert.equal(seat(game,'g').tanks,4);
assert.equal(game.emperorExtra.g ?? 0,0);
assert.equal(game.discard.filter(card => card.id === initial.karama.id).length,1);
// Cancellation must not remove the Emperor's ability to aid other members.
game = passResponses(send(game,'e',{type, target:'h', amount:2, elite:0}));
assert.equal(seat(game,'e').spice,type === 'emperorGift' ? 18 : 16);
if (type === 'emperorGift') assert.equal(seat(game,'h').spice,12);
else { assert.equal(seat(game,'h').tanks,2); assert.equal(game.emperorExtra.h,2); }
conserved(game);
});
void test('four-member Emperor revivals use separate recipient allowances and the Emperor wallet', () => {
let {game} = fixture();
for (const target of ['a','h','g']) {
const pending = send(game,'e',{type:'emperorRevival',target,amount:3,elite:0});
assert.equal(pending.response?.recipient,target);
game = passResponses(pending);
assert.equal(seat(game,target).tanks,1);
assert.equal(seat(game,target).reserves,19);
assert.equal(game.emperorExtra[target],3);
}
assert.equal(seat(game,'e').spice,2);
conserved(game);
});
+213
View File
@@ -0,0 +1,213 @@
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});}
}
});
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
beginCoalitionAdmission, answerCoalitionAdmission, coalitionAllies, coalitionGroups,
coalitionOf, emptyCoalitionState, leaveCoalition,
type CoalitionSeat, type CoalitionState, type CoalitionTransition,
} from '../game/coalition-alliance';
function initial() {
const seats: CoalitionSeat[] = [
{id:'a', ally:'b', allySinceTurn:1}, {id:'b', ally:'a', allySinceTurn:2},
...['c','d','e','f'].map(id => ({id, ally:null})),
];
return {seats, state:emptyCoalitionState('unlimited')};
}
function apply(seats: CoalitionSeat[], transition: CoalitionTransition): CoalitionState {
for (const seat of seats) {
if (Object.hasOwn(transition.allyUpdates, seat.id)) seat.ally = transition.allyUpdates[seat.id];
if (Object.hasOwn(transition.allySinceUpdates, seat.id)) {
const value = transition.allySinceUpdates[seat.id];
if (value === null) delete seat.allySinceTurn;
else seat.allySinceTurn = value;
}
}
return transition.state;
}
function invite(seats: CoalitionSeat[], state: CoalitionState, entrant: string, turn: number): CoalitionState {
const group = coalitionOf(seats, state, 'a'); assert.ok(group);
const event = `invite-${entrant}-${turn}`;
state = apply(seats, beginCoalitionAdmission(seats, state, {event, nexus:`nexus-${turn}`, turn,
groupId:group.id, entrant, proposer:'a', expectedRevision:state.revision}));
const participants = [...group.members.filter(member => member !== 'a'), entrant];
for (const actor of participants) {
const previous = coalitionOf(seats, state, 'a')!.members;
state = apply(seats, answerCoalitionAdmission(seats, state, {event, nexus:`nexus-${turn}`,
turn, actor, accept:true, expectedRevision:state.revision}));
if (actor !== entrant) assert.deepEqual(coalitionOf(seats, state, 'a')!.members, previous,
'The existing group does not expand before every participant approves.');
}
return state;
}
void test('unlimited membership grows to six with unanimous approval and retained edge ages', () => {
const {seats} = initial(); let state = emptyCoalitionState('unlimited');
for (const [index, entrant] of ['c','d','e','f'].entries()) state = invite(seats, state, entrant, index + 3);
const group = coalitionOf(seats,state,'a'); assert.ok(group);
assert.deepEqual(group.members,['a','b','c','d','e','f']);
assert.equal(group.edges.length,15);
assert.deepEqual(group.edges.find(edge => edge.members.join('') === 'ab')?.legacySince,[1,2]);
assert.equal(group.edges.find(edge => edge.members.join('') === 'ad')?.formedTurn,4);
assert.deepEqual(coalitionAllies(seats,state,'a'),['b','c','d','e','f']);
assert.ok(seats.every(seat => seat.ally === null));
assert.equal(coalitionGroups(seats, JSON.parse(JSON.stringify(state))).length,1);
const before = structuredClone(state);
assert.throws(() => beginCoalitionAdmission(seats,state,{event:'full',nexus:'n',turn:8,
groupId:group.id,entrant:'f',proposer:'a',expectedRevision:state.revision}));
assert.deepEqual(state,before);
});
void test('departures preserve surviving edges and restore the original pair scalar ages', () => {
const {seats} = initial(); let state = emptyCoalitionState('unlimited');
state = invite(seats,state,'c',3); state = invite(seats,state,'d',4);
state = apply(seats,leaveCoalition(seats,state,{actor:'d',expectedRevision:state.revision}));
let group = coalitionOf(seats,state,'a'); assert.ok(group);
assert.deepEqual(group.members,['a','b','c']); assert.equal(group.edges.length,3);
assert.equal(group.edges.find(edge => edge.members.join('') === 'ac')?.formedTurn,3);
state = apply(seats,leaveCoalition(seats,state,{actor:'c',expectedRevision:state.revision}));
group = coalitionOf(seats,state,'a'); assert.ok(group);
assert.deepEqual(group.members,['a','b']);
assert.equal(seats[0].ally,'b'); assert.equal(seats[1].ally,'a');
assert.equal(seats[0].allySinceTurn,1); assert.equal(seats[1].allySinceTurn,2);
assert.equal(seats[2].ally,null); assert.equal(seats[3].ally,null);
state = invite(seats,state,'d',5);
assert.deepEqual(coalitionOf(seats,state,'a')?.members,['a','b','d']);
assert.deepEqual(coalitionOf(seats,state,'a')?.edges.find(edge => edge.members.join('') === 'ab')?.legacySince,[1,2]);
});
void test('decline, stale answers and an omitted variant preserve the original alliance', () => {
const {seats} = initial(); let state = emptyCoalitionState('unlimited');
state = invite(seats,state,'c',3);
const group = coalitionOf(seats,state,'a'); assert.ok(group);
state = apply(seats,beginCoalitionAdmission(seats,state,{event:'invite-d',nexus:'n',turn:4,
groupId:group.id,entrant:'d',proposer:'d',expectedRevision:state.revision}));
const before = structuredClone(state);
assert.throws(() => answerCoalitionAdmission(seats,state,{event:'invite-d',nexus:'n',turn:4,
actor:'a',accept:true,expectedRevision:state.revision - 1}));
assert.deepEqual(state,before);
state = apply(seats,answerCoalitionAdmission(seats,state,{event:'invite-d',nexus:'n',turn:4,
actor:'b',accept:false,expectedRevision:state.revision}));
assert.equal(state.proposal,null); assert.deepEqual(coalitionOf(seats,state,'a')?.members,['a','b','c']);
const defaultState = {...state}; delete defaultState.variant;
assert.throws(() => beginCoalitionAdmission(seats,defaultState,{event:'new',nexus:'n',turn:4,
groupId:group.id,entrant:'d',proposer:'a',expectedRevision:defaultState.revision}));
assert.throws(() => coalitionGroups(seats,{...state,variant:'invalid'} as unknown as CoalitionState));
});
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { emptyCoalitionState, type CoalitionEdge } from '../game/coalition-alliance';
import { coalitionBattleBeneficiary } from '../game/coalition-battle-preparation';
import {
createCoalitionWormProtection, currentCoalitionWormAlly,
decideCoalitionWormProtection, protectedCoalitionWormAllies,
resolveCoalitionWormProtection, validateCoalitionWormProtection,
} from '../game/coalition-worm-protection';
import { projectFremenGroupRevivalGrant, quoteFremenRevivalGrant } from '../game/fremen-revival-grant';
import { createGame, newPlayer, type Game } from '../game/engine';
/** Staged unlimited ledger; lobby entry and actual admission are covered by
* integration tests. Every alliance edge has its original public turn. */
function source(members: string[]): Game {
const game = createGame('GROUPPOWERS', newPlayer('f', 'Fremen', 'fremen'), false);
game.players.push(newPlayer('a', 'Atreides', 'atreides'),
newPlayer('b', 'Bene Gesserit', 'beneGesserit'), newPlayer('e', 'Emperor', 'emperor'),
newPlayer('g', 'Guild', 'guild'), newPlayer('h', 'Harkonnen', 'harkonnen'));
Object.assign(game, {status:'playing', phase:1, turn:3, order:['f','e','g','a','h','b']});
const sorted = [...members].sort();
const edges: CoalitionEdge[] = [];
for (let i = 0; i < sorted.length; i++)
for (let j = i + 1; j < sorted.length; j++)
edges.push({members:[sorted[i], sorted[j]], formedTurn:2});
game.coalitionState = {...emptyCoalitionState(), variant:'unlimited' as const,
groups:[{id:'group', members:sorted, formedTurn:2, edges}]};
for (const seat of game.players) {
seat.forces = {}; seat.reserves = 20; seat.tanks = 0;
seat.ally = null;
}
return game;
}
void test('one worm resolves five independently selected allied protections in storm order', () => {
const game = source(['f','a','b','e','g','h']);
for (const seat of game.players.filter(player => player.id !== 'f')) {
seat.forces = {'red_chasm:7':3}; seat.reserves = 17;
}
let frame = createCoalitionWormProtection(game, 'f', 'red_chasm', 'worm-event');
assert.ok(frame);
assert.deepEqual(frame.order, ['e','g','a','h','b']);
for (const [ally, outcome] of [['e','allowed'], ['g','canceled'], ['a','declined'],
['h','allowed'], ['b','allowed']] as const) {
assert.equal(currentCoalitionWormAlly(game, frame), ally);
frame = decideCoalitionWormProtection(game, frame, 'f', 'worm-event', ally, outcome !== 'declined');
if (outcome !== 'declined') frame = resolveCoalitionWormProtection(game, frame, 'f', 'worm-event', ally, outcome === 'canceled');
}
assert.equal(frame.stage, 'complete');
assert.deepEqual(protectedCoalitionWormAllies(game, frame), ['e','h','b']);
assert.deepEqual(frame.outcomes.map(row => row.outcome), ['allowed','canceled','declined','allowed','allowed']);
const stale = structuredClone(frame);
stale.order = [...stale.order].reverse();
assert.throws(() => validateCoalitionWormProtection(game, stale));
});
void test('Fremen can independently select each of five allied revival recipients', () => {
const game = source(['f','a','b','e','g','h']);
game.phase = 4;
const grants = projectFremenGroupRevivalGrant(game, 'f');
assert.ok(grants);
assert.deepEqual(grants.map(row => row.recipient).sort(), ['a','b','e','g','h']);
for (const ally of ['a','b','e','g','h']) {
assert.deepEqual(quoteFremenRevivalGrant(game, 'f', ally), {turn:3,owner:'f',recipient:ally});
game.freeRevival.push(ally);
assert.throws(() => quoteFremenRevivalGrant(game, 'f', ally));
}
assert.throws(() => quoteFremenRevivalGrant(game, 'f', 'f'));
game.advanced = true;
assert.equal(projectFremenGroupRevivalGrant(game, 'f'), null);
});
void test('group battle preparation selects any single member and rejects two allied sides', () => {
const game = source(['f','a','b','e']);
game.phase = 6;
for (const ally of ['f','b','e'])
assert.equal(coalitionBattleBeneficiary(game, 'a', ally, 'g'), ally);
assert.equal(coalitionBattleBeneficiary(game, 'a', 'e', 'b'), null);
assert.equal(coalitionBattleBeneficiary(game, 'a', 'g', 'h'), null);
const unsupported = {...game, advanced:true};
assert.equal(coalitionBattleBeneficiary(unsupported, 'a', 'e', 'g'), null);
});