Files
Dune/tests/coalition-guild-shipment.test.ts
T
Quintine b89c225d5b
Verify and publish checkpoint container / container (push) Canceled after 0s
Support group Guild-rate reserve shipments
2026-10-28 23:27:49 +11:00

213 lines
11 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('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);
});