Allow group departures with bribe commitments
Verify and publish checkpoint container / container (push) Successful in 10m51s

This commit is contained in:
Quintine committed 2026-10-29 00:46:59 +11:00
1 parent 3e0fa2a8a2
commit 0faa7923b0
13 files changed
+437 -37

No files matched your search

@@ -0,0 +1,76 @@
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, actor:string, action:Action) => applyAction(game, actor, action);
const seat = (game:Game, id:string) => game.players.find(player => player.id === id)!;
/** Stage the Nexus, respondent's wallet and the two physical Truthtrance
* cards. Alliance formation, both questions and all guided actions are real. */
function initial(): {game:Game; cards:string[]} {
let game = createGame('GROUPBOTH', 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,ready:[],
order:['a','b','c','d','e'],deck:baseDeck()});
game.coalitionState = emptyCoalitionState('unlimited'); seat(game,'a').spice = 8;
game = send(game,'a',{type:'alliance',target:'b'});
game = send(game,'b',{type:'alliance',target:'a'});
const pair = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
game = send(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'c',
expectedRevision:game.coalitionState!.revision});
for (const actor of ['b','c']) {
const offer = game.coalitionState!.proposal!;
game = send(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision});
}
const cards:string[] = [];
for (let count=0;count<2;count++) {
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
const [card] = game.deck.splice(index,1); cards.push(card.id); seat(game,'e').hand.push(card);
}
return {game,cards};
}
function ask(game:Game, card:string, question:unknown, answer:'yes'|'no'):Game {
game = send(game,'e',{type:'card',card});
while(game.truthtrance?.stage === 'priority') {
const next = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(next);
game = send(game,next.id,{type:'truthPass'});
}
game = send(game,'e',{type:'truthAsk',question});
const view = viewGame(game,'a');
const answers = game.truthtrance?.question?.kind === 'bribe' ? view.truthBribeAnswers : view.truthAllianceAnswers;
assert.ok(answers?.includes(answer), `The ${answer} answer must be legal with both commitments.`);
return send(game,'a',{type:'truthAnswer',answer});
}
void test('a group alliance No and positive bribe answer retain both legal guides',()=>{
const fixture = initial();
let game = ask(fixture.game,fixture.cards[0],
{kind:'bribe',target:'a',turn:fixture.game.turn,recipient:'d',minimum:2},'yes');
game = ask(game,fixture.cards[1],
{kind:'alliance',target:'a',turn:game.turn,partner:'b'},'no');
const bribe = game.bribePromises!.find(row=>row.player === 'a')!;
const alliance = game.alliancePromises!.find(row=>row.player === 'a')!;
assert.ok(!bribe.released && !bribe.fulfilled && !alliance.released && !alliance.fulfilled);
const beforeReady = structuredClone(game);
assert.throws(()=>send(game,'a',{type:'ready'}));
assert.deepEqual(game,beforeReady);
const guide = viewGame(game,'a').alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type,'coalitionLeave');
game = send(game,'a',guide.action);
assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null);
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','c']);
assert.ok(!game.alliancePromises!.find(row=>row.event === alliance.event)!.released);
assert.ok(!game.bribePromises!.find(row=>row.event === bribe.event)!.released);
const payment = viewGame(game,'a').bribePromiseGuide; assert.ok(payment);
assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'d');
game = send(game,'a',payment.action);
assert.equal(game.bribePromises!.find(row=>row.event === bribe.event)!.fulfilled,true);
assert.equal(seat(game,'d').bribes,2);
game = send(game,'a',{type:'ready'});
assert.ok(game.ready.includes('a'));
for(const card of fixture.cards) assert.equal(game.discard.filter(row=>row.id === card).length,1);
});
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { baseDeck } from '../game/cards';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { CoalitionNexus, coalitionNexusOptions } from '../components/coalition-nexus';
const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
/** The quiet Nexus, wallet and physical Truthtrance are staged. Pair/group
* formation, question, answer, departure and payment are actual actions. */
function group(): Game {
let game = createGame('GROUPBRIBE', 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, order:['a','b','c','d','e'],
ready:[], deck:baseDeck()});
game.coalitionState = emptyCoalitionState('unlimited');
seat(game, 'a').spice = 8;
game = send(game, 'a', {type:'alliance', target:'b'});
game = send(game, 'b', {type:'alliance', target:'a'});
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
game = send(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
for (const id of ['b','c']) {
const offer = game.coalitionState!.proposal!;
game = send(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
const [card] = game.deck.splice(index, 1); seat(game, 'e').hand.push(card);
return game;
}
function ask(answer: 'yes' | 'no', expression = false): Game {
let game = group();
const card = seat(game, 'e').hand.find(row => row.effect === 'truthtrance')!;
game = send(game, 'e', {type:'card', card:card.id});
while (game.truthtrance?.stage === 'priority') {
const passer = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(passer);
game = send(game, passer.id, {type:'truthPass'});
}
const question = expression
? {kind:'bribeExpression', target:'a', turn:game.turn, claim:{kind:'and', terms:[
{kind:'payment', recipient:'d', minimum:2}, {kind:'payment', recipient:'e', minimum:1}]}}
: {kind:'bribe', target:'a', turn:game.turn, recipient:'d', minimum:2};
game = send(game, 'e', {type:'truthAsk', question});
const view = viewGame(game, 'a');
const answers = expression ? view.truthBribeExpressionAnswers : view.truthBribeAnswers;
assert.ok(answers?.includes(answer));
return send(game, 'a', {type:'truthAnswer', answer});
}
function leave(game:Game, actor:string):Game {
return send(game, actor, {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision});
}
function blockedProposal(game:Game) {
const alliance = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(alliance);
const before = structuredClone(game);
assert.throws(() => send(game, 'a', {type:'coalitionPropose', groupId:alliance.id,
entrant:'d', expectedRevision:game.coalitionState!.revision}));
assert.deepEqual(game, before);
}
void test('a positive bribe answer allows owned group departure and remains payable', () => {
let game = ask('yes');
const projected = viewGame(game, 'a').coalitionNexus; assert.ok(projected);
const options = coalitionNexusOptions(projected);
assert.equal(options.mayLeave, true); assert.equal(options.invitations.length, 0);
const markup = renderToStaticMarkup(createElement(CoalitionNexus, {view:projected, onIntent:()=>{}}));
assert.match(markup, /Leave/); assert.doesNotMatch(markup, /Invite .* to /);
blockedProposal(game);
game = leave(game, 'a');
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'b')!.members, ['b','c']);
const promise = game.bribePromises!.find(row => row.player === 'a')!;
assert.equal(promise.released ?? false, false);
const guide = viewGame(game, 'a').bribePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'bribe'); assert.equal(guide.action.target, 'd');
game = send(game, 'a', guide.action);
assert.equal(game.bribePromises!.find(row => row.event === promise.event)!.fulfilled, true);
assert.equal(seat(game, 'd').bribes, 2);
});
void test('a negative bribe answer survives another member departing and retains its cap', () => {
let game = ask('no');
blockedProposal(game);
game = leave(game, 'b');
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','c']);
const before = structuredClone(game);
assert.throws(() => send(game, 'a', {type:'bribe', target:'d', amount:2}));
assert.deepEqual(game, before);
game = send(game, 'a', {type:'bribe', target:'d', amount:1});
assert.equal(seat(game, 'd').bribes, 1);
assert.equal(game.bribePromises!.find(row => row.player === 'a')!.released ?? false, false);
});
void test('a positive bribe expression remains binding and payable after departure', () => {
let game = ask('yes', true);
blockedProposal(game);
game = leave(game, 'a');
const promise = game.bribeExpressionPromises!.find(row => row.player === 'a')!;
assert.equal(promise.released ?? false, false);
for (let step = 0; step < 2; step++) {
const guide = viewGame(game, 'a').bribePromiseGuide; assert.ok(guide);
game = send(game, 'a', guide.action);
}
assert.equal(game.bribeExpressionPromises!.find(row => row.event === promise.event)!.fulfilled, true);
assert.equal(seat(game, 'd').bribes, 2); assert.equal(seat(game, 'e').bribes, 1);
});
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
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'].map(id => ({id, name:id.toUpperCase()}));
const group: CoalitionGroup = {id:'group', members:['a','b','c'], formedTurn:1, edges:[
{members:['a','b'], formedTurn:1}, {members:['a','c'], formedTurn:1},
{members:['b','c'], formedTurn:1},
]};
const view = (me:string, admissionBlocked = false): CoalitionNexusView => ({
me, players, groups:[group], proposal:null, revision:3, variant:'unlimited', admissionBlocked,
});
const render = (value:CoalitionNexusView) => renderToStaticMarkup(createElement(CoalitionNexus, {
view:value, onIntent() {},
}));
void test('a live bribe commitment hides new invitations while retaining the owned leave control', () => {
const member = view('a', true), choices = coalitionNexusOptions(member), html = render(member);
assert.deepEqual(choices.invitations, []);
assert.equal(choices.mayLeave, true);
assert.match(html, /Invitations are unavailable while a bribe commitment remains unresolved/);
assert.match(html, /Leave A, B, C/);
assert.doesNotMatch(html, /Invite D to/);
const entrant = view('d', true);
assert.deepEqual(coalitionNexusOptions(entrant).invitations, []);
assert.equal(coalitionNexusOptions(entrant).mayLeave, false);
assert.doesNotMatch(render(entrant), /Ask to join/);
});
void test('invitations return after the block clears; a blocked pending offer can be declined', () => {
const member = view('a');
assert.deepEqual(coalitionNexusOptions(member).invitations.map(row => row.entrant), ['d']);
assert.match(render(member), /Invite D to A, B, C/);
assert.doesNotMatch(render(member), /bribe commitment remains unresolved/);
const pending = view('d', true);
pending.proposal = {event:'event', nexus:'nexus', turn:2, groupId:group.id,
original:group, entrant:'d', proposer:'a', approvals:['a']};
assert.equal(coalitionNexusOptions(pending).mayAccept, false);
assert.equal(coalitionNexusOptions(pending).mayDecline, true);
assert.doesNotMatch(render(pending), /Accept invitation/);
assert.match(render(pending), /may decline this invitation/);
});
+2 -2
View File
@@ -88,7 +88,7 @@ void test('an unresolved prior-turn Truthtrance record still blocks group Nexus'
const game = originalPair();
game.bribePromises = [{event:'unreconciled-bribe', player:'d', asker:'a', turn:game.turn - 1,
recipient:'c', minimum:1, answer:true, progress:0}];
assert.equal(viewGame(game, 'a').coalitionNexus, null);
assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
@@ -98,7 +98,7 @@ void test('a current unanswered group-incompatible Truthtrance commitment still
const game = originalPair();
game.bribePromises = [{event:'live-bribe', player:'d', asker:'a', turn:game.turn,
recipient:'c', minimum:1, answer:true, progress:0}];
assert.equal(viewGame(game, 'a').coalitionNexus, null);
assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
@@ -0,0 +1,116 @@
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 act = (game:Game, id:string, action:Action) => applyAction(game,id,action);
const seat = (game:Game, id:string) => game.players.find(player => player.id === id)!;
/** The Basic Nexus/ledger, phase boundary and wallets are staged; the pair,
* admission, held physical Truthtrance and every question/payment are real. */
function fixture(phase: 'bidding' | 'movement'):Game {
let game=createGame('OUTSIDERFUND',newPlayer('a','Atreides','atreides'),false);
game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'),
newPlayer('d','Guild','guild'),newPlayer('e','Harkonnen','harkonnen'));
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,order:['a','b','c','d','e'],
ready:[],storm:18,deck:baseDeck(),discard:[],response:null,decision:null,phaseOpening:null});
game.coalitionState=emptyCoalitionState('unlimited');
game=act(game,'a',{type:'alliance',target:'b'});
game=act(game,'b',{type:'alliance',target:'a'});
const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
game=act(game,'a',{type:'coalitionPropose',groupId:group.id,entrant:'c',
expectedRevision:game.coalitionState!.revision});
for(const id of ['b','c']) {
const offer=game.coalitionState!.proposal!;
game=act(game,id,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision});
}
for(const player of game.players) {
player.spice=10; player.hand=[]; player.forces={}; player.reserves=20; player.tanks=0;
player.traitors=[]; player.traitorChoices=[]; player.shipped=false; player.moved=0;
}
seat(game,'a').spice=2;
if(phase==='bidding') {
Object.assign(game,{phase:2,nexus:false,active:null,ready:[]});
for(const player of game.players) game=act(game,player.id,{type:'ready'});
assert.equal(game.phase,3); assert.equal(game.auction?.active,'a');
} else Object.assign(game,{phase:5,nexus:false,active:'a',ready:[],
movementRemaining:['a','b','c','d','e'],guildTimingGranted:false,guildTimingLocked:false});
const index=game.deck.findIndex(card=>card.effect==='truthtrance'); assert.ok(index>=0);
seat(game,'a').hand.push(game.deck.splice(index,1)[0]);
return game;
}
function ask(game:Game,question:Record<string,unknown>):Game {
const card=seat(game,'a').hand.find(row=>row.effect==='truthtrance'); assert.ok(card);
game=act(game,'a',{type:'card',card:card.id});
while(game.truthtrance?.stage==='priority') {
const actor=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game=act(game,actor.id,{type:'truthPass'});
}
return act(game,'a',{type:'truthAsk',question});
}
function pledge(game:Game):Game {
game=act(game,'b',{type:'coalitionPledge',recipient:'a',amount:4});
return act(game,'c',{type:'coalitionPledge',recipient:'a',amount:3});
}
function passResponses(game:Game):Game {
for(let count=0;game.response && count<30;count++) {
const actor=game.players.find(player=>!game.response!.passed.includes(player.id)); assert.ok(actor);
game=act(game,actor.id,{type:'passResponse'});
}
assert.equal(game.response ?? null,null); return game;
}
const shipment:Action={type:'ship',territory:'imperial_basin',sector:9,amount:3,elite:0,allyPayment:0,
coalitionContributions:[{donor:'b',amount:3},{donor:'c',amount:2}]};
void test('an unrelated outsider bribe No leaves group shipment funding available',()=>{
let game=fixture('movement');
game=ask(game,{kind:'bribe',target:'d',turn:game.turn,recipient:'e',minimum:1});
assert.ok(viewGame(game,'d').truthBribeAnswers?.includes('no'));
game=act(game,'d',{type:'truthAnswer',answer:'no'});
const promise=game.bribePromises!.find(row=>row.player==='d')!;
assert.equal(promise.fulfilled ?? false,false);
game=pledge(game);
assert.equal(viewGame(game,'a').coalitionFunding?.incoming.length,2);
game=passResponses(act(game,'a',shipment));
assert.equal(seat(game,'a').forces['imperial_basin:9'],3);
assert.equal(seat(game,'d').spice,16);
assert.ok(game.bribePromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released));
});
void test('an unrelated outsider whole-turn payment No leaves group shipment funding available',()=>{
let game=fixture('movement');
game=ask(game,{kind:'turnActions',target:'d',turn:game.turn,
mixed:{kind:'action',action:{kind:'payment',recipient:'e',minimum:1}}});
assert.ok(viewGame(game,'d').truthTurnActionAnswers?.includes('no'));
game=act(game,'d',{type:'truthAnswer',answer:'no'});
const promise=game.turnActionPromises!.find(row=>row.player==='d')!;
game=pledge(game); game=passResponses(act(game,'a',shipment));
assert.equal(seat(game,'a').forces['imperial_basin:9'],3);
assert.ok(game.turnActionPromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released));
});
void test('an unrelated outsider phase-bid No leaves group bid funding available',()=>{
let game=fixture('bidding');
game=ask(game,{kind:'phaseBid',target:'d',turn:game.turn,minimum:10});
assert.ok(viewGame(game,'d').truthPhaseBidAnswers?.includes('no'));
game=act(game,'d',{type:'truthAnswer',answer:'no'});
const promise=game.phaseBidPromises!.find(row=>row.player==='d')!;
game=pledge(game);
game=act(game,'a',{type:'bid',amount:6,allyPayment:0,
coalitionContributions:[{donor:'b',amount:3},{donor:'c',amount:2}]});
assert.equal(game.auction?.bidder,'a');
assert.ok(game.phaseBidPromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released));
});
void test('a group member commitment still fences the entire group funding path',()=>{
let game=fixture('movement');
game=ask(game,{kind:'bribe',target:'b',turn:game.turn,recipient:'e',minimum:1});
assert.ok(viewGame(game,'b').truthBribeAnswers?.includes('no'));
game=act(game,'b',{type:'truthAnswer',answer:'no'});
assert.equal(viewGame(game,'a').coalitionFunding,null);
const before=structuredClone(game);
assert.throws(()=>act(game,'c',{type:'coalitionPledge',recipient:'a',amount:1}));
assert.deepEqual(game,before);
});
+6 -6
View File
@@ -121,8 +121,8 @@ void test('fulfilled and released Truthtrance records leave group shipment fundi
void test('an unreconciled prior-turn commitment blocks group shipment funding', () => {
const game = fixture();
game.bribePromises = [{event:'unreconciled-bribe', player:'d', asker:'a', turn:game.turn - 1,
recipient:'c', minimum:1, answer:true, progress:0}];
game.bribePromises = [{event:'unreconciled-bribe', player:'b', asker:'a', turn:game.turn - 1,
recipient:'d', minimum:1, answer:true, progress:0}];
assert.equal(viewGame(game, 'a').coalitionFunding, null);
const before = structuredClone(game);
assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4}));
@@ -131,8 +131,8 @@ void test('an unreconciled prior-turn commitment blocks group shipment funding',
void test('a live No bribe-expression commitment blocks group shipment funding', () => {
const game = fixture();
game.bribeExpressionPromises = [{event:'live-expression-no', player:'d', asker:'a',
turn:game.turn, claim:{kind:'payment', recipient:'c', minimum:1}, answer:false, progress:[]}];
game.bribeExpressionPromises = [{event:'live-expression-no', player:'b', asker:'a',
turn:game.turn, claim:{kind:'payment', recipient:'d', minimum:1}, answer:false, progress:[]}];
assert.equal(viewGame(game, 'a').coalitionFunding, null);
const before = structuredClone(game);
assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4}));
@@ -141,8 +141,8 @@ void test('a live No bribe-expression commitment blocks group shipment funding',
void test('a current unsupported Truthtrance commitment still blocks group shipment funding', () => {
const game = fixture();
game.bribePromises = [{event:'live-bribe', player:'d', asker:'a', turn:game.turn,
recipient:'c', minimum:1, answer:true, progress:0}];
game.bribePromises = [{event:'live-bribe', player:'b', asker:'a', turn:game.turn,
recipient:'d', minimum:1, answer:true, progress:0}];
assert.equal(viewGame(game, 'a').coalitionFunding, null);
const before = structuredClone(game);
assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4}));