Support group Guild-rate reserve shipments
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-28 23:27:49 +11:00
1 parent 1eeeb32566
commit b89c225d5b
9 files changed
+297 -26

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
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';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { territory } from '../game/board';
import { reserveShipmentCost } from '../game/shipment-price';
const send = (game: Game, player: string, action: Action) => applyAction(game, player, action);
const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!;
const pledged = (game: Game, donor: string, recipient: string) =>
game.coalitionEscrow!.pledges.find(entry => entry.donor === donor && entry.recipient === recipient)?.amount ?? 0;
/** The Basic Nexus, empty group ledger and following movement boundary are
* staged because group play has no lobby entry. The pair and three-member
* group form through real actions. Wallets and forces are then reset to a
* controlled, conserved position; one original undealt Karama is moved to
* the outside Harkonnen hand so the native response remains actionable. */
function fixture(shipper: 'a' | 'g' = 'a') {
let game = createGame('GROUPGUILD', newPlayer('a', 'Atreides', 'atreides'), false);
game.players.push(newPlayer('g', 'Guild', 'guild'), newPlayer('e', 'Emperor', 'emperor'),
newPlayer('h', 'Harkonnen', 'harkonnen'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','g','e','h'],
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
game.coalitionState = emptyCoalitionState();
game = send(game, 'a', {type:'alliance', target:'g'});
game = send(game, 'g', {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:'e',
expectedRevision:game.coalitionState!.revision});
for (const id of ['g', 'e']) {
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','e','g']);
assert.ok(game.coalitionEscrow);
Object.assign(game, {phase:5, nexus:false, active:shipper, ready:[],
movementRemaining:[shipper, ...['a','g','e','h'].filter(id => id !== shipper)],
guildTimingGranted:false, guildTimingLocked:false});
for (const seat of game.players) {
seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10;
seat.hand = []; seat.traitors = []; seat.traitorChoices = []; seat.shipped = false; seat.moved = 0;
}
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [karama] = game.deck.splice(index, 1);
player(game, 'h').hand.push(karama);
return {game, karama};
}
function passResponses(game: Game): Game {
for (let i = 0; game.response && i < 24; i++) {
const next = game.players.find(seat => !game.response!.passed.includes(seat.id));
assert.ok(next, 'Each response needs a remaining eligible player.');
game = send(game, next.id, {type:'passResponse'});
}
assert.equal(game.response ?? null, null);
assert.equal(game.pendingShipment ?? null, null);
return game;
}
function rejected(game: Game, id: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => send(game, id, action));
assert.deepEqual(game, before);
}
function conserved(game: Game) {
for (const seat of game.players)
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((a,b) => a+b, 0), 20);
}
const spiceIncludingEscrow = (game: Game) => game.players.reduce((sum, seat) => sum + seat.spice, 0) +
(game.coalitionEscrow?.pledges.reduce((sum, entry) => sum + entry.amount, 0) ?? 0);
function fundAlly(game: Game) {
game = send(game, 'g', {type:'coalitionPledge', recipient:'a', amount:3});
return send(game, 'e', {type:'coalitionPledge', recipient:'a', amount:2});
}
const discounted: Action = {type:'ship', territory:'red_chasm', sector:7, amount:3, elite:0,
allyPayment:0, coalitionContributions:[{donor:'g', amount:1}, {donor:'e', amount:1}]};
void test('Guild as sole contributor pays the bank without receiving its own income', () => {
const {game: initial} = fixture();
let game = send(initial, 'g', {type:'coalitionPledge', recipient:'a', amount:3});
game = send(game, 'a', {...discounted, coalitionContributions:[{donor:'g', amount:3}]});
assert.equal(game.response?.kind, 'guildRate');
game = passResponses(game);
assert.equal(player(game, 'g').spice, 7);
assert.equal(player(game, 'a').spice, 10);
assert.equal(pledged(game, 'g', 'a'), 0);
assert.equal(spiceIncludingEscrow(game), 37);
conserved(game);
});
void test('a rounded one-force stronghold price has no separate Guild-rate cancellation', () => {
const {game: initial} = fixture();
const game = send(initial, 'a', {type:'ship', territory:'arrakeen', sector:10,
amount:1, elite:0, allyPayment:0});
assert.notEqual(game.response?.kind, 'guildRate');
assert.equal(game.pendingShipment ?? null, null);
assert.equal(player(game, 'a').spice, 9);
assert.equal(player(game, 'a').forces['arrakeen:10'], 1);
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty} proposes an affordable group Guild-rate shipment`, () => {
const {game} = fixture();
player(game, 'a').spice = 3;
const view = viewGame(game, 'a');
view.players.find(seat => seat.id === 'a')!.bot = difficulty;
const shipment = botActions(view).find(action => action.type === 'ship');
assert.ok(shipment);
const cost = reserveShipmentCost({faction:'atreides', halfRate:true},
territory(String(shipment.territory)).type, Number(shipment.amount));
assert.ok(cost <= 3);
const result = send(game, 'a', shipment);
if (result.pendingShipment) assert.equal(result.pendingShipment.cost, cost);
else assert.equal(player(game, 'a').spice - player(result, 'a').spice, cost);
});
void test('a self-funded group ally receives the Guild rate without declaring donor shares', () => {
const {game: initial} = fixture();
const declared = send(initial, 'a', {type:'ship', territory:'red_chasm', sector:7,
amount:3, elite:0, allyPayment:0});
assert.equal(declared.response?.kind, 'guildRate');
assert.equal(declared.pendingShipment?.cost, 3);
const done = passResponses(declared);
assert.equal(player(done, 'a').spice, 7);
assert.equal(player(done, 'g').spice, 13);
assert.equal(player(done, 'a').forces['red_chasm:7'], 3);
conserved(done);
});
void test('Guild-allied group shipment uses half price and sends each non-Guild payment to Guild', () => {
const {game: initial} = fixture();
const funded = fundAlly(initial);
const declared = send(funded, 'a', discounted);
assert.equal(declared.response?.kind, 'guildRate');
assert.equal(declared.pendingShipment?.cost, 3);
assert.equal(player(declared, 'a').spice, 10);
assert.equal(player(declared, 'a').reserves, 20);
assert.equal(pledged(declared, 'g', 'a'), 3);
assert.equal(pledged(declared, 'e', 'a'), 2);
const done = passResponses(declared);
assert.equal(player(done, 'a').spice, 9);
assert.equal(player(done, 'g').spice, 9, 'Guild receives Atreides and Emperor payments, not its own contribution.');
assert.equal(player(done, 'e').spice, 8);
assert.equal(pledged(done, 'g', 'a'), 2);
assert.equal(pledged(done, 'e', 'a'), 1);
assert.equal(player(done, 'a').forces['red_chasm:7'], 3);
assert.equal(player(done, 'a').reserves, 17);
assert.equal(spiceIncludingEscrow(done), 39, 'Guild’s own one-spice contribution went to the bank.');
conserved(done);
});
void test('Karama removes the group Guild discount and preserves the declared donor shares', () => {
const {game: initial, karama} = fixture();
const declared = send(fundAlly(initial), 'a', discounted);
assert.equal(declared.response?.kind, 'guildRate');
const after = send(declared, 'h', {type:'card', mode:'cancel', card:karama.id});
const done = passResponses(after);
assert.equal(player(done, 'a').spice, 6, 'The shipper covers the full-price remainder of four.');
assert.equal(player(done, 'g').spice, 12, 'Only the non-Guild payer legs reach Guild.');
assert.equal(player(done, 'e').spice, 8);
assert.equal(pledged(done, 'g', 'a'), 2);
assert.equal(pledged(done, 'e', 'a'), 1);
assert.equal(player(done, 'a').forces['red_chasm:7'], 3);
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
assert.equal(spiceIncludingEscrow(done), 39);
rejected(done, 'h', {type:'card', mode:'cancel', card:karama.id});
conserved(done);
});
void test('unaffordable canceled group rate leaves all pledged shares and forces untouched', () => {
const {game: initial, karama} = fixture();
player(initial, 'a').spice = 1;
const declared = send(fundAlly(initial), 'a', discounted);
assert.equal(declared.pendingShipment?.cost, 3);
const after = send(declared, 'h', {type:'card', mode:'cancel', card:karama.id});
assert.equal(after.pendingShipment ?? null, null);
assert.equal(player(after, 'a').spice, 1);
assert.equal(player(after, 'a').reserves, 20);
assert.equal(player(after, 'a').shipped, false);
assert.equal(pledged(after, 'g', 'a'), 3);
assert.equal(pledged(after, 'e', 'a'), 2);
assert.equal(after.discard.filter(card => card.id === karama.id).length, 1);
assert.equal(viewGame(after, 'a').guildRateCanceled, true);
const replacement: Action = {...discounted, coalitionContributions:[{donor:'g', amount:3}, {donor:'e', amount:2}]};
const done = passResponses(send(after, 'a', replacement));
assert.equal(player(done, 'a').spice, 0);
assert.equal(player(done, 'a').forces['red_chasm:7'], 3);
assert.equal(pledged(done, 'g', 'a'), 0);
assert.equal(pledged(done, 'e', 'a'), 0);
conserved(done);
});
void test('Guild shipping as a group member charges rounded half price and collects both allied contributions', () => {
const {game: initial} = fixture('g');
let game = send(initial, 'a', {type:'coalitionPledge', recipient:'g', amount:1});
game = send(game, 'e', {type:'coalitionPledge', recipient:'g', amount:1});
game = send(game, 'g', {type:'ship', territory:'arrakeen', sector:10, amount:3, elite:0, allyPayment:0,
coalitionContributions:[{donor:'a', amount:1}, {donor:'e', amount:1}]});
assert.equal(game.response?.kind, 'guildRate');
assert.equal(game.pendingShipment?.cost, 2, 'Three stronghold forces cost ceil(3 / 2).');
game = passResponses(game);
assert.equal(player(game, 'g').spice, 12);
assert.equal(player(game, 'a').spice, 9);
assert.equal(player(game, 'e').spice, 9);
assert.equal(pledged(game, 'a', 'g'), 0);
assert.equal(pledged(game, 'e', 'g'), 0);
assert.equal(player(game, 'g').forces['arrakeen:10'], 3);
assert.equal(spiceIncludingEscrow(game), 40);
conserved(game);
});