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); });