Support staged group shipment funding
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-28 23:14:44 +11:00
1 parent 624f95dde4
commit 34aa64cdef
13 files changed
+828 -16

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { reconcileCoalitionEscrow, setCoalitionPledge, type CoalitionEscrowState,
type CoalitionEscrowMembership } from '../game/coalition-escrow';
import type { CoalitionState } from '../game/coalition-alliance';
const empty: CoalitionEscrowState = { authorizations: [], pledges: [], usedEpochs: [] };
const seats = (values: [string, string | null][]) => values.map(([id, ally]) => ({id, ally}));
const unallied: CoalitionEscrowMembership = { seats: seats([['a', null], ['b', null], ['c', null]]) };
const pair: CoalitionEscrowMembership = { seats: seats([['a', 'b'], ['b', 'a'], ['c', null]]) };
const bcPair: CoalitionEscrowMembership = { seats: seats([['a', null], ['b', 'c'], ['c', 'b']]) };
const tripleState: CoalitionState = {version: 1, revision: 1, proposal: null, usedEvents: [], groups: [{
id: 'abc', members: ['a', 'b', 'c'], formedTurn: 2, edges: [
{members: ['a', 'b'], formedTurn: 2}, {members: ['a', 'c'], formedTurn: 3},
{members: ['b', 'c'], formedTurn: 3},
],
}]};
const triple: CoalitionEscrowMembership = {
seats: seats([['a', null], ['b', null], ['c', null]]), coalitionState: tripleState,
};
const ab = {members: ['a', 'b'] as const, epoch: 'ab1'};
const ac = {members: ['a', 'c'] as const, epoch: 'ac1'};
const bc = {members: ['b', 'c'] as const, epoch: 'bc1'};
function startedPair() {
return reconcileCoalitionEscrow({before: unallied, after: pair, escrow: empty, newAuthorizations: [ab]}).escrow;
}
function startedTriple() {
return reconcileCoalitionEscrow({before: pair, after: triple, escrow: startedPair(), newAuthorizations: [ac, bc]}).escrow;
}
void test('each new edge receives its own epoch while the existing edge retains its epoch', () => {
const first = startedPair(), original = structuredClone(first);
const result = reconcileCoalitionEscrow({before: pair, after: triple, escrow: first, newAuthorizations: [ac, bc]});
assert.deepEqual(result.escrow.authorizations, [ab, ac, bc]);
assert.deepEqual(result.escrow.usedEpochs, ['ab1', 'ac1', 'bc1']);
assert.deepEqual(result.refunds, []);
assert.deepEqual(first, original);
assert.deepEqual(JSON.parse(JSON.stringify(result)), result);
assert.throws(() => reconcileCoalitionEscrow({before: pair, after: triple, escrow: first, newAuthorizations: [ac]}), /fresh epoch/);
assert.throws(() => reconcileCoalitionEscrow({before: pair, after: triple, escrow: first, newAuthorizations: [ac, ac, bc]}), /uniquely/);
assert.throws(() => reconcileCoalitionEscrow({before: pair, after: triple, escrow: first,
newAuthorizations: [{...ac, epoch: 'ab1'}, bc]}), /reused/);
});
void test('recipient-specific pledges retain independent balances and protect committed spice', () => {
const initial = startedTriple(), before = structuredClone(initial);
const one = setCoalitionPledge({...triple, escrow: initial, donor: 'a', recipient: 'b', amount: 6, available: 10});
assert.equal(one.walletDelta, -6);
const two = setCoalitionPledge({...triple, escrow: one.escrow, donor: 'a', recipient: 'c', amount: 3, available: 4});
assert.equal(two.walletDelta, -3);
const reduced = setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'b', amount: 4, available: 1, locked: 4});
assert.equal(reduced.walletDelta, 2);
assert.deepEqual(reduced.escrow.pledges, [
{donor: 'a', recipient: 'c', amount: 3, epoch: 'ac1'},
{donor: 'a', recipient: 'b', amount: 4, epoch: 'ab1'},
]);
assert.throws(() => setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'b', amount: 3, available: 1, locked: 4}), /Committed/);
assert.throws(() => setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'b', amount: 8, available: 1}), /lacks/);
assert.throws(() => setCoalitionPledge({...triple, escrow: two.escrow, donor: 'a', recipient: 'a', amount: 1, available: 1}), /valid donor/);
assert.deepEqual(initial, before);
});
void test('a departing member receives each refund and surviving allies keep their edge and escrow', () => {
let ledger = startedTriple();
for (const [donor, recipient, amount] of [['a', 'b', 3], ['a', 'c', 4], ['b', 'c', 5], ['c', 'a', 2]] as const)
ledger = setCoalitionPledge({...triple, escrow: ledger, donor, recipient, amount, available: 20}).escrow;
const before = structuredClone(ledger);
const result = reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: ledger, newAuthorizations: []});
assert.deepEqual(result.escrow.authorizations, [bc]);
assert.deepEqual(result.escrow.pledges, [{donor: 'b', recipient: 'c', amount: 5, epoch: 'bc1'}]);
assert.deepEqual(result.refunds, [{donor: 'a', amount: 7}, {donor: 'c', amount: 2}]);
assert.deepEqual(ledger, before);
assert.throws(() => reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: ledger, newAuthorizations: [],
locked: [{donor: 'a', recipient: 'b', amount: 1, epoch: 'ab1'}]}), /Resolve committed/);
});
void test('same-turn reformation requires fresh epochs and cannot reactivate old authorization', () => {
const old = startedTriple();
const left = reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: old, newAuthorizations: []}).escrow;
assert.throws(() => reconcileCoalitionEscrow({before: bcPair, after: triple, escrow: left, newAuthorizations: [ab, ac]}), /reused/);
const result = reconcileCoalitionEscrow({before: bcPair, after: triple, escrow: left,
newAuthorizations: [{...ab, epoch: 'ab2'}, {...ac, epoch: 'ac2'}]});
assert.deepEqual(result.escrow.authorizations, [{...ab, epoch: 'ab2'}, {...ac, epoch: 'ac2'}, bc]);
assert.deepEqual(result.escrow.usedEpochs, ['ab1', 'ac1', 'bc1', 'ab2', 'ac2']);
});
void test('overflow, duplicate or malformed ledgers and forged locks fail without mutation', () => {
const huge: CoalitionEscrowState = {...startedTriple(), pledges: [
{donor: 'a', recipient: 'b', amount: Number.MAX_SAFE_INTEGER, epoch: 'ab1'},
{donor: 'a', recipient: 'c', amount: 1, epoch: 'ac1'},
]};
const before = structuredClone(huge);
assert.throws(() => reconcileCoalitionEscrow({before: triple, after: bcPair, escrow: huge, newAuthorizations: []}), /safe integers/);
assert.throws(() => setCoalitionPledge({...triple, escrow: huge, donor: 'a', recipient: 'c', amount: 0, available: Number.MAX_SAFE_INTEGER}), /safe integers/);
assert.throws(() => reconcileCoalitionEscrow({before: triple, after: triple, escrow: huge, newAuthorizations: [],
locked: [{donor: 'a', recipient: 'c', amount: 2, epoch: 'ac1'}]}), /match its available/);
assert.throws(() => reconcileCoalitionEscrow({before: triple, after: triple, escrow: {...huge, usedEpochs: ['ab1', 'ac1', 'ac1', 'bc1']}, newAuthorizations: []}), /unique valid/);
assert.throws(() => setCoalitionPledge({...triple, escrow: {...huge, pledges: [...huge.pledges, huge.pledges[0]]}, donor: 'a', recipient: 'b', amount: 1, available: 1}), /only one pledge/);
assert.deepEqual(huge, before);
});
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { CoalitionFunding, coalitionFundingContributions, coalitionPledgeAction,
type CoalitionFundingView } from '../components/coalition-funding';
const view: CoalitionFundingView = {
targets: [
{ id: 'b', name: 'Emperor', pledged: 2, maximum: 6 },
{ id: 'c', name: 'Fremen', pledged: 1, maximum: 5 },
],
incoming: [
{ donor: 'b', name: 'Emperor', amount: 3 },
{ donor: 'c', name: 'Fremen', amount: 2 },
],
blocked: null,
};
void test('each outgoing ally has a separate labelled pledge and incoming amount', () => {
const html = renderToStaticMarkup(createElement(CoalitionFunding, {
view, act() {}, onContributionsChange() {},
}));
assert.match(html, /Pledge to Emperor · 2 spice currently set aside/);
assert.match(html, /Pledge to Fremen · 1 spice currently set aside/);
assert.match(html, /Set funding for Emperor/);
assert.match(html, /Set funding for Fremen/);
assert.match(html, /Emperor: 3 spice/);
assert.match(html, /Fremen: 2 spice/);
assert.match(html, /Use from Emperor for your next shipment/);
assert.match(html, /Use from Fremen for your next shipment/);
});
void test('pledge action uses the named recipient and validates whole, safe limits', () => {
const target = view.targets[0];
assert.deepEqual(coalitionPledgeAction(target, '6', null),
{ type: 'coalitionPledge', recipient: 'b', amount: 6 });
assert.deepEqual(coalitionPledgeAction(target, '0', null),
{ type: 'coalitionPledge', recipient: 'b', amount: 0 });
for (const invalid of ['', '-1', '1.5', '7', '01', '1e2', '9007199254740992'])
assert.equal(coalitionPledgeAction(target, invalid, null), null);
assert.equal(coalitionPledgeAction(target, '2', 'Finish the pending payment.'), null);
assert.equal(coalitionPledgeAction(target, '2', null, true), null);
});
void test('shipment selections retain donor identity and clamp to live pledges', () => {
assert.deepEqual(coalitionFundingContributions(view.incoming, {b: 9, c: 1.9, missing: 8}), [
{ donor: 'b', amount: 3 }, { donor: 'c', amount: 1 },
]);
assert.deepEqual(coalitionFundingContributions(view.incoming, {b: -2, c: 0}), []);
assert.deepEqual(coalitionFundingContributions([{donor: 'b', name: 'Emperor', amount: 1}],
{b: 3, c: 2}), [{ donor: 'b', amount: 1 }]);
});
void test('busy or blocked controls cannot submit and omit shipment inputs when not requested', () => {
const blocked = renderToStaticMarkup(createElement(CoalitionFunding, {
view: {...view, blocked: 'Finish the pending payment.'}, act() {},
}));
assert.match(blocked, /Finish the pending payment/);
assert.match(blocked, /disabled=""[^>]*>Set funding for Emperor/);
assert.doesNotMatch(blocked, /Use from Emperor for your next shipment/);
const busy = renderToStaticMarkup(createElement(CoalitionFunding, { view, busy: true, act() {} }));
assert.match(busy, /disabled=""[^>]*>Set funding for Fremen/);
});
+171
View File
@@ -0,0 +1,171 @@
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, player: string, action: Action) => applyAction(game, player, action);
/** The empty ledger and Nexus are staged because no lobby enables the group
* mode yet. The pair, invitation and unanimous group formation are actual
* actions. The subsequent quiet movement boundary and starting wallets are
* staged; each faction's force inventory remains conserved. */
function fixture(): Game {
let game = createGame('GROUPFUND', newPlayer('a', 'Atreides', 'atreides'), false);
game.players.push(newPlayer('b', 'Emperor', 'emperor'),
newPlayer('c', 'Fremen', 'fremen'), newPlayer('d', 'Guild', 'guild'));
Object.assign(game, {status: 'playing', phase: 1, turn: 2, nexus: true,
order: ['a', 'b', 'c', 'd'], ready: [], storm: 18, deck: baseDeck()});
game.coalitionState = emptyCoalitionState();
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});
}
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a', 'b', 'c']);
assert.ok(game.coalitionEscrow, 'Real group formation initialized the escrow.');
Object.assign(game, {phase: 5, nexus: false, active: 'a', ready: [],
movementRemaining: ['a', 'b', 'c', 'd'], guildTimingGranted: false,
guildTimingLocked: false});
for (const player of game.players) {
player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10;
player.hand = []; player.traitors = []; player.traitorChoices = [];
player.shipped = false; player.moved = 0;
}
game.players[0].spice = 2;
return game;
}
function pledge(game: Game): Game {
game = send(game, 'b', {type: 'coalitionPledge', recipient: 'a', amount: 4});
return send(game, 'c', {type: 'coalitionPledge', recipient: 'a', amount: 3});
}
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}]};
function finishResponses(game: Game): Game {
for (let step = 0; game.response && step < 12; step++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(actor, 'Every income response must have a remaining voter.');
game = send(game, actor.id, {type: 'passResponse'});
}
assert.equal(game.response ?? null, null);
assert.equal(game.pendingShipment ?? null, null);
return game;
}
function remaining(game: Game, donor: string, recipient = 'a'): number {
return game.coalitionEscrow!.pledges.find(pledge => pledge.donor === donor &&
pledge.recipient === recipient)?.amount ?? 0;
}
function conservedForces(game: Game) {
for (const player of game.players)
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((n, value) => n + value, 0), 20);
}
function spiceIncludingEscrow(game: Game): number {
return game.players.reduce((n, player) => n + player.spice, 0) +
(game.coalitionEscrow?.pledges.reduce((n, pledge) => n + pledge.amount, 0) ?? 0);
}
void test('two allies fund one ordinary shipment with donor-specific settlement and Guild income', () => {
let game = fixture();
const originalSpice = spiceIncludingEscrow(game);
game = pledge(game);
assert.equal(game.players[1].spice, 6);
assert.equal(game.players[2].spice, 7);
assert.equal(remaining(game, 'b'), 4);
assert.equal(remaining(game, 'c'), 3);
assert.deepEqual(viewGame(game, 'a').coalitionFunding?.incoming.map(({donor, amount}) => ({donor, amount})),
[{donor:'b', amount:4}, {donor:'c', amount:3}]);
assert.equal(viewGame(game, 'd').coalitionFunding, null);
assert.deepEqual(viewGame(game, 'b').coalitionFunding?.incoming, []);
game = send(game, 'a', shipment);
if (game.response) assert.equal(game.response.kind, 'guildIncome');
game = finishResponses(game);
assert.equal(game.players[0].spice, 1, 'The shipper pays the one-spice remainder.');
assert.equal(game.players[1].spice, 6, 'Already escrowed contributions are not charged twice.');
assert.equal(game.players[2].spice, 7);
assert.equal(remaining(game, 'b'), 1);
assert.equal(remaining(game, 'c'), 1);
assert.equal(game.players[3].spice, 16, 'The outsider Guild receives the full six-spice invoice.');
assert.equal(game.players[0].forces['imperial_basin:9'], 3);
assert.equal(game.players[0].reserves, 17);
assert.equal(game.players[0].shipped, true);
conservedForces(game);
assert.equal(spiceIncludingEscrow(game), originalSpice);
});
void test('fake or insufficient donor contributions reject without payment or arrival', () => {
const game = pledge(fixture());
for (const contributions of [
[{donor: 'd', amount: 3}],
[{donor: 'b', amount: 5}],
[{donor: 'b', amount: 3}, {donor: 'b', amount: 1}],
[{donor: 'b', amount: 3}], // Shipper cannot afford the three-spice remainder.
]) {
const before = structuredClone(game);
assert.throws(() => send(game, 'a', {...shipment, coalitionContributions: contributions}));
assert.deepEqual(game, before);
}
});
void test('unspent group shipment pledges return to their actual donors when movement ends', () => {
let game = finishResponses(send(pledge(fixture()), 'a', shipment));
for (let step = 0; game.phase === 5 && step < 20; step++) {
if (game.response) { game = finishResponses(game); continue; }
if (game.decision?.kind === 'guildTiming') {
game = send(game, game.decision.player, {type: 'decision', take: false});
continue;
}
assert.ok(game.active, 'A live movement turn must have an actor.');
game = send(game, game.active, {type: 'endMovement'});
}
assert.notEqual(game.phase, 5, 'The native movement queue completes.');
assert.equal(game.players[1].spice, 7);
assert.equal(game.players[2].spice, 8);
assert.equal(remaining(game, 'b'), 0);
assert.equal(remaining(game, 'c'), 0);
assert.equal(spiceIncludingEscrow(game), 32);
conservedForces(game);
});
void test('one donor receives both separate unspent pledges at phase end', () => {
let game = fixture();
game = send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:3});
game = send(game, 'b', {type:'coalitionPledge', recipient:'c', amount:2});
assert.equal(game.players[1].spice, 5);
for (let step = 0; game.phase === 5 && step < 20; step++) {
if (game.response) { game = finishResponses(game); continue; }
if (game.decision?.kind === 'guildTiming') {
game = send(game, game.decision.player, {type:'decision', take:false});
continue;
}
assert.ok(game.active);
game = send(game, game.active, {type:'endMovement'});
}
assert.notEqual(game.phase, 5);
assert.equal(game.players[1].spice, 10);
assert.equal(game.coalitionEscrow!.pledges.length, 0);
assert.equal(spiceIncludingEscrow(game), 32);
});
void test('leaving refunds only broken group edges and retains the surviving pair pledge', () => {
let game = fixture();
game = send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:3});
game = send(game, 'b', {type:'coalitionPledge', recipient:'c', amount:2});
const oldEpoch = game.coalitionEscrow!.pledges.find(item => item.recipient === 'a')!.epoch;
// Stage a later quiet Nexus; normal phase ending would already refund these pledges.
Object.assign(game, {phase:1, nexus:true, active:null, ready:[]});
game = send(game, 'c', {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision});
assert.equal(game.players[1].spice, 7);
assert.equal(remaining(game, 'b', 'a'), 3);
assert.equal(remaining(game, 'b', 'c'), 0);
assert.equal(game.coalitionEscrow!.pledges.find(item => item.recipient === 'a')!.epoch, oldEpoch);
assert.equal(spiceIncludingEscrow(game), 32);
});
+10
View File
@@ -4,6 +4,7 @@ import { baseDeck } from '../game/cards';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { createRicheseNoField } from '../game/richese-no-field';
import type { CoalitionState } from '../game/coalition-alliance';
import { isAlliedShipmentVisit } from '../game/allied-separation';
/** Controlled Basic phase and force placement; the three-member group is
* staged because Nexus cannot create one yet. No full-game claim is made. */
@@ -68,6 +69,15 @@ void test('ordinary movement cannot enter either group ally territory', () => {
assert.deepEqual(game, before);
});
void test('the player view gives the shared transit quote the public group membership', () => {
const game = fixture();
const view = viewGame(game, 'a');
const own = view.players.find(player => player.id === 'a')!;
assert.equal(isAlliedShipmentVisit(view, own, 'imperial_basin'), true);
assert.equal(isAlliedShipmentVisit(view, own, 'polar_sink'), false);
assert.equal(Object.hasOwn(view, 'coalitionEscrow'), false);
});
void test('a three-member group visitor can ship then depart without losing its pre-arrival forces', () => {
const original = fixture(); let game = ship(original);
assert.equal(game.players[0].spice, 4);
+26
View File
@@ -43,6 +43,32 @@ void test('staged group mode renders its real Nexus invitation and hides conflic
assert.match(markup(game, 'h', true), /disabled=""[^>]*>Accept invitation/);
});
void test('staged group shipment funding appears on the live table for the donor and recipient', () => {
let game = nexusOfferGame();
game.coalitionState = emptyCoalitionState();
game = applyAction(game, 'a', {type:'alliance', target:'e'});
game = applyAction(game, 'e', {type:'alliance', target:'a'});
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
game = applyAction(game, 'a', {type:'coalitionPropose', groupId:pair.id,
entrant:'h', expectedRevision:game.coalitionState!.revision});
for (const id of ['e', 'h']) {
const offer = game.coalitionState!.proposal!;
game = applyAction(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
// Stage the next movement boundary; the group negotiation above used real actions.
Object.assign(game, {phase:5, nexus:false, active:'a', ready:[], movementRemaining:['a','e','h']});
const emperor = game.players.find(player => player.id === 'e')!;
assert.ok(emperor.reserves > 0);
emperor.reserves--;
emperor.forces['arrakeen:10'] = (emperor.forces['arrakeen:10'] ?? 0) + 1;
game = applyAction(game, 'e', {type:'coalitionPledge', recipient:'a', amount:1});
assert.match(markup(game, 'a'), /Emperor recipient: 1 spice/);
assert.match(markup(game, 'a'), /Use from Emperor recipient for your next shipment/);
assert.match(markup(game, 'a'), /Basic allied transit: ship forces/);
assert.match(markup(game, 'e'), /Pledge to Atreides offerer · 1 spice currently set aside/);
});
void test('live outgoing offer is named and withdrawable only by its sender, separately from an incoming invitation', () => {
const g = applyAction(nexusOfferGame(), 'a', { type: 'alliance', target: 'e' });
const html = markup(g);