Files
Dune/tests/coalition-guild-shipment.test.ts
T
Quintine 9cc659602c
Verify and publish checkpoint container / container (push) Canceled after 0s
Support group-funded Karama shipment pricing
2026-10-29 00:09:16 +11:00

237 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('a group-funded Karama shipment sends every payment to the bank', () => {
const {game: initial, karama} = fixture();
// Move the fixture's physical card to the shipper before buying the rate.
player(initial, 'h').hand = player(initial, 'h').hand.filter(card => card.id !== karama.id);
player(initial, 'a').hand.push(karama);
let game = fundAlly(initial);
game = send(game, 'a', {type:'card', card:karama.id, mode:'shipment'});
assert.equal(game.karamaShipping?.player, 'a');
assert.equal(viewGame(game, 'a').coalitionFunding?.incoming.length, 2,
'Both existing pledges remain available after purchasing the rate.');
game = send(game, 'a', discounted);
assert.equal(game.response ?? null, null, 'Karama shipment payments do not trigger Guild income.');
assert.equal(game.karamaShipping, null);
assert.equal(player(game, 'a').spice, 9);
assert.equal(player(game, 'g').spice, 7, 'Guild receives no payer share.');
assert.equal(player(game, 'e').spice, 8);
assert.equal(pledged(game, 'g', 'a'), 2);
assert.equal(pledged(game, 'e', 'a'), 1);
assert.equal(spiceIncludingEscrow(game), 37, 'All three spice went to the bank.');
assert.equal(player(game, 'a').forces['red_chasm:7'], 3);
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
conserved(game);
});
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);
});