Support staged group shipment funding
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
624f95dde4
commit
34aa64cdef
13 files changed
+828
-16
No files matched your search
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user