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'; const send = (game: Game, player: string, action: Action) => applyAction(game, player, action); /** The empty ledger and Nexus are staged because no lobby enables the group * mode yet. The pair, invitation and unanimous group formation are actual * actions. The subsequent quiet movement boundary and starting wallets are * staged; each faction's force inventory remains conserved. */ function fixture(): Game { let game = createGame('GROUPFUND', newPlayer('a', 'Atreides', 'atreides'), false); game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'), newPlayer('d', 'Guild', 'guild')); Object.assign(game, {status: 'playing', phase: 1, turn: 2, nexus: true, order: ['a', 'b', 'c', 'd'], ready: [], storm: 18, deck: baseDeck()}); game.coalitionState = emptyCoalitionState(); game = send(game, 'a', {type: 'alliance', target: 'b'}); game = send(game, 'b', {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: 'c', expectedRevision: game.coalitionState!.revision}); for (const id of ['b', 'c']) { 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', 'b', 'c']); assert.ok(game.coalitionEscrow, 'Real group formation initialized the escrow.'); Object.assign(game, {phase: 5, nexus: false, active: 'a', ready: [], movementRemaining: ['a', 'b', 'c', 'd'], guildTimingGranted: false, guildTimingLocked: false}); for (const player of game.players) { player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10; player.hand = []; player.traitors = []; player.traitorChoices = []; player.shipped = false; player.moved = 0; } game.players[0].spice = 2; return game; } function pledge(game: Game): Game { game = send(game, 'b', {type: 'coalitionPledge', recipient: 'a', amount: 4}); return send(game, 'c', {type: 'coalitionPledge', recipient: 'a', amount: 3}); } const shipment: Action = {type: 'ship', territory: 'imperial_basin', sector: 9, amount: 3, elite: 0, allyPayment: 0, coalitionContributions: [{donor: 'b', amount: 3}, {donor: 'c', amount: 2}]}; function finishResponses(game: Game): Game { for (let step = 0; game.response && step < 12; step++) { const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor, 'Every income response must have a remaining voter.'); game = send(game, actor.id, {type: 'passResponse'}); } assert.equal(game.response ?? null, null); assert.equal(game.pendingShipment ?? null, null); return game; } function remaining(game: Game, donor: string, recipient = 'a'): number { return game.coalitionEscrow!.pledges.find(pledge => pledge.donor === donor && pledge.recipient === recipient)?.amount ?? 0; } function conservedForces(game: Game) { for (const player of game.players) assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((n, value) => n + value, 0), 20); } function spiceIncludingEscrow(game: Game): number { return game.players.reduce((n, player) => n + player.spice, 0) + (game.coalitionEscrow?.pledges.reduce((n, pledge) => n + pledge.amount, 0) ?? 0); } void test('two allies fund one ordinary shipment with donor-specific settlement and Guild income', () => { let game = fixture(); const originalSpice = spiceIncludingEscrow(game); game = pledge(game); assert.equal(game.players[1].spice, 6); assert.equal(game.players[2].spice, 7); assert.equal(remaining(game, 'b'), 4); assert.equal(remaining(game, 'c'), 3); assert.deepEqual(viewGame(game, 'a').coalitionFunding?.incoming.map(({donor, amount}) => ({donor, amount})), [{donor:'b', amount:4}, {donor:'c', amount:3}]); assert.equal(viewGame(game, 'd').coalitionFunding, null); assert.deepEqual(viewGame(game, 'b').coalitionFunding?.incoming, []); game = send(game, 'a', shipment); if (game.response) assert.equal(game.response.kind, 'guildIncome'); game = finishResponses(game); assert.equal(game.players[0].spice, 1, 'The shipper pays the one-spice remainder.'); assert.equal(game.players[1].spice, 6, 'Already escrowed contributions are not charged twice.'); assert.equal(game.players[2].spice, 7); assert.equal(remaining(game, 'b'), 1); assert.equal(remaining(game, 'c'), 1); assert.equal(game.players[3].spice, 16, 'The outsider Guild receives the full six-spice invoice.'); assert.equal(game.players[0].forces['imperial_basin:9'], 3); assert.equal(game.players[0].reserves, 17); assert.equal(game.players[0].shipped, true); conservedForces(game); assert.equal(spiceIncludingEscrow(game), originalSpice); }); void test('fulfilled and released Truthtrance records leave group shipment funding usable', () => { for (const history of ['settled', 'prior-turn'] as const) { const initial = fixture(); initial.bribePromises = [{event:`bribe-${history}`, player:'d', asker:'a', turn:history === 'prior-turn' ? initial.turn - 1 : initial.turn, recipient:'c', minimum:1, answer:true, progress:history === 'settled' ? 1 : 0, ...(history === 'settled' ? {fulfilled:true} : {released:true})}]; let game = pledge(initial); assert.equal(viewGame(game, 'a').coalitionFunding?.incoming.length, 2); game = finishResponses(send(game, 'a', shipment)); assert.equal(game.players[0].forces['imperial_basin:9'], 3); assert.equal(remaining(game, 'b'), 1); assert.equal(remaining(game, 'c'), 1); assert.deepEqual(game.bribePromises, initial.bribePromises); conservedForces(game); } }); void test('an unreconciled prior-turn commitment blocks group shipment funding', () => { const game = fixture(); game.bribePromises = [{event:'unreconciled-bribe', player:'d', asker:'a', turn:game.turn - 1, recipient:'c', minimum:1, answer:true, progress:0}]; assert.equal(viewGame(game, 'a').coalitionFunding, null); const before = structuredClone(game); assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4})); assert.deepEqual(game, before); }); void test('a live No bribe-expression commitment blocks group shipment funding', () => { const game = fixture(); game.bribeExpressionPromises = [{event:'live-expression-no', player:'d', asker:'a', turn:game.turn, claim:{kind:'payment', recipient:'c', minimum:1}, answer:false, progress:[]}]; assert.equal(viewGame(game, 'a').coalitionFunding, null); const before = structuredClone(game); assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4})); assert.deepEqual(game, before); }); void test('a current unsupported Truthtrance commitment still blocks group shipment funding', () => { const game = fixture(); game.bribePromises = [{event:'live-bribe', player:'d', asker:'a', turn:game.turn, recipient:'c', minimum:1, answer:true, progress:0}]; assert.equal(viewGame(game, 'a').coalitionFunding, null); const before = structuredClone(game); assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4})); assert.deepEqual(game, before); }); void test('fake or insufficient donor contributions reject without payment or arrival', () => { const game = pledge(fixture()); for (const contributions of [ [{donor: 'd', amount: 3}], [{donor: 'b', amount: 5}], [{donor: 'b', amount: 3}, {donor: 'b', amount: 1}], [{donor: 'b', amount: 3}], // Shipper cannot afford the three-spice remainder. ]) { const before = structuredClone(game); assert.throws(() => send(game, 'a', {...shipment, coalitionContributions: contributions})); assert.deepEqual(game, before); } }); void test('unspent group shipment pledges return to their actual donors when movement ends', () => { let game = finishResponses(send(pledge(fixture()), 'a', shipment)); for (let step = 0; game.phase === 5 && step < 20; step++) { if (game.response) { game = finishResponses(game); continue; } if (game.decision?.kind === 'guildTiming') { game = send(game, game.decision.player, {type: 'decision', take: false}); continue; } assert.ok(game.active, 'A live movement turn must have an actor.'); game = send(game, game.active, {type: 'endMovement'}); } assert.notEqual(game.phase, 5, 'The native movement queue completes.'); assert.equal(game.players[1].spice, 7); assert.equal(game.players[2].spice, 8); assert.equal(remaining(game, 'b'), 0); assert.equal(remaining(game, 'c'), 0); assert.equal(spiceIncludingEscrow(game), 32); conservedForces(game); }); void test('one donor receives both separate unspent pledges at phase end', () => { let game = fixture(); game = send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:3}); game = send(game, 'b', {type:'coalitionPledge', recipient:'c', amount:2}); assert.equal(game.players[1].spice, 5); for (let step = 0; game.phase === 5 && step < 20; step++) { if (game.response) { game = finishResponses(game); continue; } if (game.decision?.kind === 'guildTiming') { game = send(game, game.decision.player, {type:'decision', take:false}); continue; } assert.ok(game.active); game = send(game, game.active, {type:'endMovement'}); } assert.notEqual(game.phase, 5); assert.equal(game.players[1].spice, 10); assert.equal(game.coalitionEscrow!.pledges.length, 0); assert.equal(spiceIncludingEscrow(game), 32); }); void test('leaving refunds only broken group edges and retains the surviving pair pledge', () => { let game = fixture(); game = send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:3}); game = send(game, 'b', {type:'coalitionPledge', recipient:'c', amount:2}); const oldEpoch = game.coalitionEscrow!.pledges.find(item => item.recipient === 'a')!.epoch; // Stage a later quiet Nexus; normal phase ending would already refund these pledges. Object.assign(game, {phase:1, nexus:true, active:null, ready:[]}); game = send(game, 'c', {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision}); assert.equal(game.players[1].spice, 7); assert.equal(remaining(game, 'b', 'a'), 3); assert.equal(remaining(game, 'b', 'c'), 0); assert.equal(game.coalitionEscrow!.pledges.find(item => item.recipient === 'a')!.epoch, oldEpoch); assert.equal(spiceIncludingEscrow(game), 32); });