Prototype unlimited alliance variant
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user