import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game, } from '../game/engine'; import { baseDeck } from '../game/cards'; type Route = 'reserve' | 'cross' | 'return'; type Direction = 'guildShips' | 'guildFunds' | 'otherFunds'; function player(g: Game, id: string) { return g.players.find((p) => p.id === id)!; } function fixture(direction: Direction, route: Route, guildPresent = true) { let g = createGame('PAYROUTE', newPlayer('e', 'Emperor', 'emperor')); joinGame(g, newPlayer('a', 'Atreides', 'atreides')); if (guildPresent) joinGame(g, newPlayer('g', 'Guild', 'guild')); g.players.forEach((p) => (p.ready = true)); g = applyAction(g, 'e', { type: 'start' }); for (const p of g.players) if (p.traitorChoices.length) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0], }); assert.equal(g.status, 'playing'); g.phase = 5; g.turn = 2; g.storm = 18; g.order = g.players.map((p) => p.id); g.movementRemaining = [...g.order]; g.deck = baseDeck(); g.discard = []; for (const p of g.players) { p.forces = {}; p.reserves = 20; p.spice = 20; p.hand = []; p.traitors = []; } const shipper = direction === 'guildShips' ? 'g' : 'e'; const donor = direction === 'guildFunds' ? 'g' : direction === 'guildShips' ? 'e' : 'a'; player(g, shipper).ally = donor; player(g, donor).ally = shipper; g.active = shipper; if (route !== 'reserve') { player(g, shipper).forces = { 'arrakeen:10': 4 }; player(g, shipper).reserves = 16; } g = applyAction(g, donor, { type: 'pledgeAid', amount: 4 }); return { g, shipper, donor }; } function holdKarama(g: Game, id: string) { const index = g.deck.findIndex((card) => card.effect === 'karama'); assert.ok(index >= 0); const card = g.deck.splice(index, 1)[0]; player(g, id).hand.push(card); return card; } function shipment(route: Route, allyPayment?: number): Action { return { type: route === 'reserve' ? 'ship' : 'guildShip', ...(route === 'reserve' ? {} : { from: 'arrakeen:10' }), territory: route === 'return' ? 'reserves' : 'carthag', sector: route === 'return' ? 0 : 11, amount: 4, ...(allyPayment === undefined ? {} : { allyPayment }), }; } function assertArrival(g: Game, shipper: string, route: Route) { const p = player(g, shipper); assert.equal(p.shipped, true); assert.equal(p.moved, 0); assert.equal(p.forces['arrakeen:10'] ?? 0, 0); assert.equal(p.forces['carthag:11'] ?? 0, route === 'return' ? 0 : 4); assert.equal(p.reserves, route === 'return' ? 20 : 16); } function allowIncome(g: Game) { let next = g; for (let i = 0; next.response && i < 3; i++) { assert.equal(next.response.kind, 'guildIncome'); const responder = next.players.find((p) => { const controls = viewGame(next, p.id).responseControls; return controls && !controls.hasPassed && controls.cancelCards.length; }); assert.ok(responder); next = applyAction(next, responder.id, { type: 'passResponse' }); } assert.equal(next.response, null); return next; } for (const route of ['reserve', 'cross', 'return'] as const) void test(`non-Guild ally funding a Guild ${route} shipment credits only the contributor's share at zero, partial and full splits`, () => { for (const split of [0, 1, 2]) { const { g, shipper, donor } = fixture('guildShips', route); const before = structuredClone(g); const after = applyAction(g, shipper, shipment(route, split)); // Cost two: Guild pays its own share to the bank; the non-Guild contribution goes to Guild. assert.equal(player(after, 'g').spice, 20 - (2 - split) + split); assert.equal(player(after, donor).spice, 16); assert.equal(after.aid[donor].amount, 4 - split); assert.equal(after.response, null); assertArrival(after, shipper, route); assert.deepEqual(g, before); } }); for (const route of ['reserve', 'cross'] as const) void test(`Guild-funded allied ${route} shipment sends Guild's contribution to the bank and only the shipper's payment to Guild`, () => { for (const split of [0, 1, 2]) { const { g, shipper, donor } = fixture('guildFunds', route); const after = applyAction(g, shipper, shipment(route, split)); assert.equal(player(after, shipper).spice, 20 - (2 - split)); assert.equal(player(after, 'g').spice, 16 + (2 - split)); assert.equal(after.aid[donor].amount, 4 - split); assert.equal(after.response, null); assertArrival(after, shipper, route); } }); void test('a third-party Guild receives both non-Guild payment shares, while its absence sends both shares to the bank', () => { for (const guildPresent of [true, false]) for (const split of [0, 2, 4]) { const { g, shipper, donor } = fixture( 'otherFunds', 'reserve', guildPresent, ); const after = applyAction(g, shipper, shipment('reserve', split)); assert.equal(player(after, shipper).spice, 20 - (4 - split)); assert.equal(player(after, donor).spice, 16); assert.equal(after.aid[donor].amount, 4 - split); if (guildPresent) assert.equal(player(after, 'g').spice, 24); assert.equal(after.response, null); assertArrival(after, shipper, 'reserve'); } }); void test('default contribution uses the pre-income budget, and malformed explicit splits cannot spend or create spice', () => { for (const route of ['reserve', 'cross', 'return'] as const) { const { g, shipper, donor } = fixture('guildShips', route); player(g, shipper).spice = 0; const after = applyAction(g, shipper, shipment(route)); assert.equal(player(after, 'g').spice, 2); assert.equal(after.aid[donor].amount, 2); assertArrival(after, shipper, route); for (const split of [-1, 1, 3, 1.5, Number.MAX_SAFE_INTEGER + 1]) { const before = structuredClone(g); assert.throws(() => applyAction(g, shipper, shipment(route, split))); assert.deepEqual(g, before); } } }); void test('Guild income from either contributor direction can be allowed or canceled without refunding or replaying the shipment', () => { for (const direction of ['guildShips', 'guildFunds'] as const) for (const route of (direction === 'guildShips' ? ['reserve', 'cross', 'return'] : ['reserve', 'cross']) as Route[]) for (const cancel of [false, true]) { const { g, shipper, donor } = fixture(direction, route); const card = holdKarama(g, 'a'); const beforeGuild = player(g, 'g').spice; let pending = applyAction(g, shipper, shipment(route, 1)); // Native cross-planet transport first opens its permission window. if (pending.response?.kind === 'guildTransportPermission') pending = applyAction(pending, 'a', { type: 'passResponse' }); assert.equal(pending.response?.kind, 'guildRate'); assert.equal(player(pending, shipper).shipped, false); pending = applyAction(pending, 'a', { type: 'passResponse' }); assert.equal(pending.response?.kind, 'guildIncome'); assert.equal(pending.response?.owner, 'g'); assert.equal(pending.response?.amount, 1); const paidGuild = beforeGuild - (shipper === 'g' ? 1 : 0); assert.equal(player(pending, 'g').spice, paidGuild); assert.equal(pending.aid[donor].amount, 3); assertArrival(pending, shipper, route); const restored = JSON.parse(JSON.stringify(pending)) as Game; const done = cancel ? applyAction(restored, 'a', { type: 'card', mode: 'cancel', card: card.id, }) : allowIncome(restored); assert.equal(player(done, 'g').spice, paidGuild + (cancel ? 0 : 1)); assert.equal(done.aid[donor].amount, 3); assertArrival(done, shipper, route); assert.equal(done.response, null); assert.equal( done.discard.filter((c) => c.id === card.id).length, cancel ? 1 : 0, ); const beforeRetry = structuredClone(done); assert.throws(() => applyAction(done, shipper, shipment(route, 1))); assert.deepEqual(done, beforeRetry); } }); void test('an independently purchased Karama shipment rate sends every contributor share to the bank', () => { for (const direction of ['guildShips', 'guildFunds', 'otherFunds'] as const) for (const route of (direction === 'guildShips' ? ['reserve', 'cross', 'return'] : direction === 'guildFunds' ? ['reserve', 'cross'] : ['reserve']) as Route[]) for (const split of [0, 1, 2]) { const { g: initial, shipper, donor } = fixture(direction, route); let g = initial; const owner = direction === 'otherFunds' ? 'g' : 'a'; const rate = holdKarama(g, owner); g = applyAction(g, owner, { type: 'card', mode: 'shipment', target: shipper, card: rate.id, }); assert.equal(g.karamaShipping?.player, shipper); const beforeGuild = player(g, 'g').spice; const after = applyAction(g, shipper, shipment(route, split)); assert.equal( player(after, shipper).spice, player(g, shipper).spice - (2 - split), ); assert.equal( player(after, 'g').spice, beforeGuild - (shipper === 'g' ? 2 - split : 0), ); assert.equal(after.aid[donor].amount, 4 - split); assert.equal(after.response, null); assert.equal(after.karamaShipping, null); assert.equal(after.discard.filter((c) => c.id === rate.id).length, 1); assertArrival(after, shipper, route); } });