237 lines
12 KiB
TypeScript
237 lines
12 KiB
TypeScript
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);
|
||
});
|