import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { quoteHomeworldPaymentIncome } from '../game/homeworld-payment-income'; import type { OccupiedPercentageReceipt } from '../game/homeworld-occupied-percentage'; import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation'; import { assertClassicHomeworldSkillsCustody } from './fixture-homeworld-classic-skills'; import { advanceHomeworldBanker, allocateHomeworldBankerPercentage, allowHomeworldBankerIncome, cancelHomeworldBankerIncome, chooseHomeworldBankerBureaucrat, createHomeworldBankerFixture, declareHomeworldBankerInvoice, holdHomeworldBankerKarama, homeworldBankerCards, homeworldBankerClean, homeworldBankerPlayer, homeworldBankerShipment, homeworldBankerWallets, settleHomeworldBankerInvoice, type HomeworldBankerFixture, } from './fixture-homeworld-banker-income'; function addedSources(before: Game, after: Game) { const sources = after.spiceBankerIncome!.sources; return sources.slice(before.spiceBankerIncome!.sources.length); } function addedLegs(before: Game, after: Game) { return addedSources(before, after).flatMap(row => row.source.bankLegs.map(leg => ({ ...leg }))); } function addedGrants(before: Game, after: Game) { return addedSources(before, after).filter(row => row.grant !== null); } function deferred(game: Game, owner: string) { return viewGame(game, owner).spiceBankerIncome!.deferred.find(row => row.owner === owner)?.amount ?? 0; } function receipt(game: Game) { return game.homeworldOccupiedPercentageLedger!.receipts.at(-1)!; } /** The frozen receipt keeps its original producer proof; read it only after the * discriminant is checked. */ function guildShippingSource(row: OccupiedPercentageReceipt) { const source = row.source; if (source.kind !== 'guild-shipping') throw new Error('Expected the original Guild shipping source.'); return source; } function reject(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before, 'A rejected action must leave the immutable state unchanged.'); } /** The payer and the pledged ally pay their original shares exactly once; only * the receipt's bank/settled shares leave or re-enter seated wallets. */ function expectWallets(f: HomeworldBankerFixture, done: Game, nativeShare: number, occupiedShare = 0) { const expected = homeworldBankerWallets(f.beforePledge); expected[f.payer] -= f.payerShare; expected[f.ally] -= f.allyShare; if (nativeShare) expected[f.payee] += nativeShare; if (occupiedShare) expected[f.occupier!] += occupiedShare; assert.deepEqual(homeworldBankerWallets(done), expected); assert.equal(done.aid[f.ally]?.amount ?? 0, 0, 'The pledge is spent once.'); assert.deepEqual(homeworldBankerCards(done), f.physical); assertClassicHomeworldSkillsCustody(done); } void test('fresh classic Homeworld Banker admission keeps the original board, fourteen skills and separate owners', () => { const f = createHomeworldBankerFixture(); assert.equal(f.initial.spiceBankerIncomePreview, true); assert.equal(f.initial.homeworldOccupationPreview, true); assert.equal(f.initial.pendingHomeworldOccupiedIncome, null); assert.deepEqual(f.initial.homeworldOccupiedIncomeHistory, []); assert.ok(f.initial.homeworldOccupiedPercentageLedger, 'Ordinary fresh World Skills admission supplies its original occupation ledger.'); assert.deepEqual(f.initial.homeworldOccupiedPercentageLedger.receipts, []); assert.ok(f.initial.spiceBankerIncome, 'The fresh Homeworld Banker game owns the real income ledger.'); assert.equal(f.initial.status, 'setup'); assert.equal(f.setup.status, 'playing'); const banker = f.setup.leaderSkills!.assignments.find(assignment => assignment.skill === 'spice-banker')!; assert.equal(banker.owner, f.trainer); assert.equal(banker.leader, f.leader); assert.equal(homeworldBankerPlayer(f.setup, f.trainer).leaders.find(leader => leader.id === f.leader)!.dead, false); assert.notEqual(f.bureaucrat, f.trainer); assert.notEqual(f.bureaucrat, f.payer); assert.notEqual(f.bureaucrat, f.payee); assert.notEqual(f.trainer, f.payee); assert.equal(homeworldBankerPlayer(f.setup, f.bureaucrat).faction, 'fremen'); assert.equal(homeworldBankerPlayer(f.setup, f.payee).reserves, 15, 'Junction starts with the native fifteen.'); assert.equal(homeworldBankerPlayer(f.setup, f.payee).forces['tueks_sietch:5'], 5); assert.equal(homeworldBankerPlayer(f.setup, f.bureaucrat).forces['sietch_tabr:14'], 10, 'The Fremen star placement is a real setup action.'); assert.ok(f.setup.players.every(player => !Object.keys(player.forces).some(key => key.startsWith('polar_sink')))); assert.deepEqual(homeworldBankerCards(f.setup), homeworldBankerCards(f.initial), 'No physical card was dealt or lost.'); assertClassicHomeworldSkillsCustody(f.setup); }); void test('high Junction keeps the whole prep invoice, then the native tariff banks its own gross once', () => { const f = createHomeworldBankerFixture(); assert.equal(f.prepReceipt.source.kind, 'guild-shipping'); assert.equal(f.prepReceipt.source.native, f.payee); assert.equal(f.prepReceipt.source.amount, 4); assert.equal(f.prepReceipt.nativeLow, false); assert.equal(f.prepReceipt.nativeRetained, 4); assert.equal(f.prepReceipt.occupiedAmount, 0); assert.equal(f.prepReceipt.bankRetained, 0, 'A high-population invoice leaves no Bank remainder.'); assert.equal(f.prepReceipt.status, 'settled'); assert.equal(f.prepaid.spiceBankerIncome!.sources.length, 0, 'No Bank payment existed before the native tariff.'); // The Guild's own fifteen-force tariff is a real Bank payment of its gross cost. const tariff = f.junctionEmpty.spiceBankerIncome!.sources.at(-1)!; assert.deepEqual(tariff.source.bankLegs, [{ payer: f.payee, amount: 8 }]); assert.equal(tariff.source.kind, 'shipment'); assert.equal(tariff.grant?.owner, f.trainer); assert.equal(homeworldBankerPlayer(f.junctionEmpty, f.payee).forces['habbanya_ridge_sietch:17'], 15); // Junction is genuinely low, and the vacated stronghold is a real move. assert.equal(quoteHomeworldPaymentIncome(f.junctionEmpty, f.payee, 'shipment', 8).low, true); assert.equal(homeworldBankerPlayer(f.junctionEmpty, f.payee).reserves, 0); assert.ok(f.actions.some(step => step.action.type === 'move' && step.actor === f.payee), 'The real Guild movement that vacates Tuek’s Sietch stays in the trace.'); assert.equal(homeworldBankerPlayer(f.vacated, f.payee).forces[`pasty_mesa:${f.vacateSector}`], 5); assert.ok(f.vacated.players.every(player => !Object.keys(player.forces).some(key => key.startsWith('tueks_sietch')))); }); void test('the low unoccupied eight-spice invoice pays each original leg once and stays below the Bank boundary', () => { const f = createHomeworldBankerFixture(); const declared = declareHomeworldBankerInvoice(f); assert.equal(homeworldBankerPlayer(declared, f.payer).spice, homeworldBankerPlayer(f.beforePledge, f.payer).spice - f.payerShare); assert.equal(homeworldBankerPlayer(declared, f.ally).spice, homeworldBankerPlayer(f.beforePledge, f.ally).spice - f.allyShare); assert.equal(declared.decision?.kind, 'bureaucratPayment', 'The real income window resolves through its original pass queue into the private Bureaucrat offer.'); assert.equal(homeworldBankerPlayer(declared, f.payee).spice, homeworldBankerPlayer(f.beforePledge, f.payee).spice); const done = settleHomeworldBankerInvoice(f, declared); const row = receipt(done), source = guildShippingSource(row); assert.equal(source.amount, f.gross); assert.equal(source.nativeIncome, 'received'); assert.deepEqual(source.payment.contributions, f.contributions); assert.equal(row.nativeLow, true); assert.equal(row.nativeRetained, 4); assert.equal(row.occupiedAmount, 0); assert.equal(row.bankRetained, 4); assert.equal(row.entitlement, null, 'No occupation exists, so no percentage allocation opens.'); assert.equal(row.status, 'settled'); // The final Bank legs keep distinct payers and no leg reaches four spice. assert.deepEqual(addedLegs(f.game, done), [{ payer: f.payer, amount: 3 }, { payer: f.ally, amount: 1 }]); assert.equal(addedGrants(f.game, done).length, 0, 'A three-spice primary leg never qualifies.'); assert.equal(deferred(done, f.trainer), deferred(f.game, f.trainer)); expectWallets(f, done, 4); assert.equal(done.bureaucratPayments?.used.length ?? 0, 0); assert.equal(viewGame(done, f.bureaucrat).bureaucrat.usedThisPhase, false); assert.equal(addedSources(f.game, done).length, 1, 'The invoice records its own single receipt.'); reject(declared, f.payer, f.action); }); void test('the private Bureaucrat diversion combines with the same payer’s remainder and pays at the real Mentat', () => { const f = createHomeworldBankerFixture(); const declared = declareHomeworldBankerInvoice(f); const pending = allowHomeworldBankerIncome(declared); assert.equal(pending.decision?.kind, 'bureaucratPayment'); assert.equal(pending.decision?.player, f.bureaucrat); for (const viewer of pending.players) { const view = viewGame(pending, viewer.id), invoice = view.bureaucrat.pending!; assert.equal(invoice.payer, null, 'Allied funding stays private.'); assert.equal(invoice.payee, f.payee); assert.equal(invoice.owner, f.bureaucrat); assert.equal(invoice.amount, f.gross); assert.equal(invoice.homeworldAllocation, true); for (const field of ['contributions', 'shares', 'qualifyingPayer', 'signature', 'source', 'privateFunding']) assert.equal(field in invoice, false, `${field} is private funding evidence, not a public invoice`); } reject(pending, f.payer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true }); reject(pending, f.bureaucrat, { type: 'decision', event: 'expired-bureaucrat-event', redirect: true }); const redirected = chooseHomeworldBankerBureaucrat(pending, true); assert.equal(redirected.bureaucratPayments?.used.length, 1); assert.equal(viewGame(redirected, f.bureaucrat).bureaucrat.usedThisPhase, true); assert.notEqual(redirected.decision?.kind, 'homeworldOccupiedPercentage', 'An unoccupied world opens no percentage allocation.'); const done = settleHomeworldBankerInvoice(f, declared, true); const row = receipt(done), source = guildShippingSource(row); assert.equal(source.amount, f.gross - 2); assert.equal(source.payment.paid, f.gross); assert.equal(source.payment.bankDiversion, 2); assert.deepEqual(source.payment.contributions, f.contributions); assert.equal(row.nativeRetained, 3); assert.equal(row.occupiedAmount, 0); assert.equal(row.bankRetained, 3); assert.deepEqual(addedLegs(f.game, done), [{ payer: f.payer, amount: 4 }, { payer: f.ally, amount: 1 }], 'The original two spice join that payer’s own Bank remainder, and never the ally’s leg.'); const grants = addedGrants(f.game, done); assert.equal(grants.length, 1); assert.equal(grants[0].grant?.owner, f.trainer); assert.equal(deferred(done, f.trainer), deferred(f.game, f.trainer) + 1); assert.equal(homeworldBankerPlayer(done, f.trainer).spice, homeworldBankerPlayer(f.beforePledge, f.trainer).spice, 'The grant stays in front of the shield.'); expectWallets(f, done, 3); assert.equal(done.bureaucratPayments?.used.length, 1); // Only the real Mentat Pause collects the deferred spice. const atCollection = advanceHomeworldBanker(done, state => state.phase === 7 && homeworldBankerClean(state)); const beforeMentat = homeworldBankerPlayer(atCollection, f.trainer).spice; const mentat = advanceHomeworldBanker(atCollection, state => state.phase === 8); assert.equal(homeworldBankerPlayer(mentat, f.trainer).spice, beforeMentat + 1); assert.deepEqual(mentat.spiceBankerIncome!.collections.at(-1)?.credits, [{ owner: f.trainer, amount: 1 }]); assert.deepEqual(viewGame(mentat, f.trainer).spiceBankerIncome!.deferred, []); assertClassicHomeworldSkillsCustody(mentat); }); void test('a Karama cancellation banks the whole low invoice and only the five-spice primary leg qualifies', () => { const f = createHomeworldBankerFixture(); const karama = holdHomeworldBankerKarama(f); const declared = declareHomeworldBankerInvoice(f); assert.equal(declared.response?.kind, 'guildIncome'); reject(declared, f.payee, { type: 'card', mode: 'cancel', card: karama }); reject(declared, f.ally, { type: 'card', mode: 'cancel', card: karama }); const done = cancelHomeworldBankerIncome(f, declared); assert.equal(done.response, null); assert.equal(done.decision, null); assert.equal(done.discard.filter(card => card.id === karama).length, 1); assert.equal(done.bureaucratPayments?.pending, undefined); const row = receipt(done), source = guildShippingSource(row); assert.equal(source.nativeIncome, 'karama-canceled'); assert.equal(source.amount, f.gross); assert.equal(row.nativeRetained, 0); assert.equal(row.occupiedAmount, 0); assert.equal(row.bankRetained, f.gross, 'Canceled ordinary income leaves all remaining cash in the Bank.'); assert.deepEqual(addedLegs(f.game, done), f.contributions); const grants = addedGrants(f.game, done); assert.equal(grants.length, 1); assert.equal(deferred(done, f.trainer), deferred(f.game, f.trainer) + 1); expectWallets(f, done, 0); assert.equal(homeworldBankerPlayer(done, f.payee).spice, homeworldBankerPlayer(f.beforePledge, f.payee).spice, 'A canceled income credits the native nothing.'); }); void test('an occupied low Junction pays only the occupier’s allocated share as player income', () => { const f = createHomeworldBankerFixture({ occupied: true }); assert.ok(f.occupied); const authority = quoteStableHomeworldOccupation(f.occupied!, 'junction'); assert.equal(authority.blocked, null); assert.equal(authority.entitlement?.occupier, f.occupier); assert.deepEqual(f.occupied!.homeworlds!.custody!.visitors['homeworld:guild'], { [f.occupier!]: { normal: 1, elite: 0 } }); const done = settleHomeworldBankerInvoice(f, declareHomeworldBankerInvoice(f)); const row = receipt(done); assert.equal(row.source.amount, f.gross); assert.equal(row.nativeRetained, 4); assert.equal(row.occupiedAmount, 4); assert.equal(row.bankRetained, 0); assert.equal(row.ownAmount, 4); assert.deepEqual(row.credits, [{ player: f.occupier, amount: 4 }]); assert.equal(row.entitlement?.occupier, f.occupier); assert.deepEqual(addedSources(f.game, done), [], 'Occupier cash is player income; an occupied invoice leaves the Bank nothing to award.'); assert.equal(deferred(done, f.trainer), deferred(f.game, f.trainer)); expectWallets(f, done, 4, 4); assert.equal(done.bureaucratPayments?.used.length ?? 0, 0); }); void test('an occupied invoice with a private diversion banks exactly the diversion and grants nothing', () => { const f = createHomeworldBankerFixture({ occupied: true }); const declared = declareHomeworldBankerInvoice(f); const pending = allowHomeworldBankerIncome(declared); assert.equal(pending.decision?.kind, 'bureaucratPayment'); const redirected = chooseHomeworldBankerBureaucrat(pending, true); assert.equal(redirected.decision?.kind, 'homeworldOccupiedPercentage'); assert.equal(redirected.decision?.player, f.occupier); const offer = viewGame(redirected, f.occupier!).homeworldOccupiedPercentage!; assert.equal(offer.world, 'homeworld:guild'); assert.equal(offer.amount, 3); assert.equal(offer.ally, null); reject(redirected, f.payee, { type: 'decision', event: offer.event, ownAmount: offer.maxOwnAmount }); reject(redirected, f.occupier!, { type: 'decision', event: 'stale-percentage-event', ownAmount: 0 }); reject(redirected, f.occupier!, { type: 'decision', event: offer.event, ownAmount: 0 }); const done = allocateHomeworldBankerPercentage(redirected); const row = receipt(done), source = guildShippingSource(row); assert.equal(source.amount, f.gross - 2); assert.equal(source.payment.bankDiversion, 2); assert.equal(row.nativeRetained, 3); assert.equal(row.occupiedAmount, 3); assert.equal(row.bankRetained, 0, 'The diversion is taken before the occupied split.'); assert.deepEqual(row.credits, [{ player: f.occupier, amount: 3 }]); assert.deepEqual(addedLegs(f.game, done), [{ payer: f.payer, amount: 2 }], 'Only the two-spice diversion reaches the Bank, and never pooled with the ally.'); assert.equal(addedGrants(f.game, done).length, 0, 'Two spice alone never reaches the printed boundary.'); assert.equal(deferred(done, f.trainer), deferred(f.game, f.trainer)); expectWallets(f, done, 3, 3); }); void test('an occupied cancellation banks the native half only and leaves three/one below the boundary', () => { const f = createHomeworldBankerFixture({ occupied: true }); holdHomeworldBankerKarama(f); const declared = declareHomeworldBankerInvoice(f); const canceled = cancelHomeworldBankerIncome(f, declared); assert.equal(canceled.response, null); assert.equal(canceled.bureaucratPayments?.pending, undefined); assert.equal(canceled.decision?.kind, 'homeworldOccupiedPercentage'); assert.equal(canceled.decision?.player, f.occupier); const done = allocateHomeworldBankerPercentage(canceled); const row = receipt(done), source = guildShippingSource(row); assert.equal(source.nativeIncome, 'karama-canceled'); assert.equal(source.amount, f.gross); assert.equal(row.nativeRetained, 0); assert.equal(row.occupiedAmount, 4, 'The canceled native half does not cancel the printed occupied share.'); assert.equal(row.bankRetained, 4); assert.deepEqual(row.credits, [{ player: f.occupier, amount: 4 }]); assert.deepEqual(addedLegs(f.game, done), [{ payer: f.payer, amount: 3 }, { payer: f.ally, amount: 1 }]); assert.equal(addedGrants(f.game, done).length, 0, 'A final three-spice leg never qualifies.'); assert.equal(homeworldBankerPlayer(done, f.payee).spice, homeworldBankerPlayer(f.beforePledge, f.payee).spice); expectWallets(f, done, 0, 4); }); for (const [payerShare, expected] of [ [5, [{ payer: 'payer', amount: 3 }, { payer: 'ally', amount: 3 }]], [6, [{ payer: 'payer', amount: 4 }, { payer: 'ally', amount: 2 }]], ] as const) { void test(`the odd eleven-spice stronghold invoice orders its extra Bank unit on the declared primary first (${payerShare}+${11 - payerShare})`, () => { const f = createHomeworldBankerFixture({ occupied: true, stronghold: true, payerShare }); assert.equal(f.gross, 11); assert.equal(f.allyShare, 11 - payerShare); assert.equal(f.target, 'tueks_sietch'); const reserves = homeworldBankerPlayer(f.game, f.payer).reserves; const karama = holdHomeworldBankerKarama(f); const declared = declareHomeworldBankerInvoice(f); const canceled = cancelHomeworldBankerIncome(f, declared); assert.equal(canceled.decision?.kind, 'homeworldOccupiedPercentage'); const done = allocateHomeworldBankerPercentage(canceled); const row = receipt(done), source = guildShippingSource(row); assert.equal(source.amount, 11); assert.equal(source.nativeIncome, 'karama-canceled'); assert.equal(row.nativeRetained, 0); assert.equal(row.occupiedAmount, 5); assert.equal(row.bankRetained, 6); assert.deepEqual(addedLegs(f.game, done), expected.map(leg => ({ payer: leg.payer === 'payer' ? f.payer : f.ally, amount: leg.amount, }))); const grants = addedGrants(f.game, done); assert.equal(grants.length, payerShare === 5 ? 0 : 1, 'Only a final four-spice primary leg reaches the printed boundary.'); assert.equal(homeworldBankerPlayer(done, f.payer).reserves, reserves - 11); assert.equal(homeworldBankerPlayer(done, f.payer).forces['tueks_sietch:5'], 11); assert.equal(done.discard.filter(card => card.id === karama).length, 1); expectWallets(f, done, 0, 5); }); } void test('the stronghold diversion selects the first eligible contribution in declared order', () => { const f = createHomeworldBankerFixture({ occupied: true, stronghold: true, payerShare: 5 }); assert.deepEqual(f.contributions, [{ payer: f.payer, amount: 5 }, { payer: f.ally, amount: 6 }]); const declared = declareHomeworldBankerInvoice(f); const pending = allowHomeworldBankerIncome(declared); assert.equal(pending.decision?.kind, 'bureaucratPayment'); const done = settleHomeworldBankerInvoice(f, declared, true); const row = receipt(done), source = guildShippingSource(row); assert.equal(source.amount, 9); assert.equal(source.payment.bankDiversion, 2); assert.equal(row.occupiedAmount, 4); assert.deepEqual(addedLegs(f.game, done), [{ payer: f.payer, amount: 2 }], 'The first eligible contribution is the declared primary payer, not the larger ally.'); assert.equal(addedGrants(f.game, done).length, 0); expectWallets(f, done, 5, 4); }); void test('a below-five primary leaves the private diversion to the ally contribution', () => { const f = createHomeworldBankerFixture({ occupied: true, stronghold: true, payerShare: 4 }); const declared = declareHomeworldBankerInvoice(f); const pending = allowHomeworldBankerIncome(declared); assert.equal(pending.decision?.kind, 'bureaucratPayment'); const done = settleHomeworldBankerInvoice(f, declared, true); const row = receipt(done); assert.equal(row.source.amount, 9); assert.equal(row.occupiedAmount, 4); assert.deepEqual(addedLegs(f.game, done), [{ payer: f.ally, amount: 2 }], 'The first contribution of at least five spice is the ally’s.'); assert.equal(addedGrants(f.game, done).length, 0); expectWallets(f, done, 5, 4); }); void test('a second qualifying Bank payment in the same phase adds no further grant', () => { const f = createHomeworldBankerFixture({ payerShare: 6 }); const karama = holdHomeworldBankerKarama(f); const declared = declareHomeworldBankerInvoice(f); const canceled = cancelHomeworldBankerIncome(f, declared); assert.equal(addedGrants(f.game, canceled).length, 1); const granted = deferred(canceled, f.trainer); const second = advanceHomeworldBanker(canceled, state => state.turn === 3 && state.phase === 5 && state.active === f.ally && homeworldBankerClean(state)); const payment = homeworldBankerShipment(second, f.ally, 'tueks_sietch', 8); const settled = advanceHomeworldBanker(applyAction(second, f.ally, payment), homeworldBankerClean); const added = addedSources(canceled, settled); assert.equal(added.length, 1, 'The second real payment still records its own Bank legs.'); assert.deepEqual(added[0].source.bankLegs, [{ payer: f.ally, amount: 4 }]); assert.equal(added[0].grant, null, 'The physical skill already earned this phase.'); assert.equal(deferred(settled, f.trainer), granted); assert.equal(viewGame(settled, f.trainer).spiceBankerIncome!.usedThisPhase.includes(f.trainer), true); assert.equal(canceled.discard.filter(card => card.id === karama).length, 1); }); void test('a below-threshold Bank remainder creates no Banker payment at all', () => { const f = createHomeworldBankerFixture(); const done = settleHomeworldBankerInvoice(f, declareHomeworldBankerInvoice(f)); const second = advanceHomeworldBanker(done, state => state.turn === 3 && state.phase === 5 && state.active === f.ally && homeworldBankerClean(state)); const payment = homeworldBankerShipment(second, f.ally, 'tueks_sietch', 2); const settled = advanceHomeworldBanker(applyAction(second, f.ally, payment), homeworldBankerClean); const added = addedSources(done, settled); assert.deepEqual(added.flatMap(row => row.source.bankLegs), [{ payer: f.ally, amount: 1 }], 'The true one-spice Bank remainder is recorded without any grant.'); assert.equal(added.every(row => row.grant === null), true); assert.equal(deferred(settled, f.trainer), deferred(done, f.trainer)); const row = receipt(settled); assert.equal(row.source.amount, 2); assert.equal(row.nativeRetained, 1); assert.equal(row.bankRetained, 1); });