import assert from 'node:assert/strict'; import test from 'node:test'; import { BankerIncomeError, commitBankerIncome, commitBankerIncomeCollection, createBankerIncomeState, projectBankerIncome, quoteBankerIncome, quoteBankerIncomeCollection, validateBankerIncomeSource, validateBankerIncomeState, type BankerIncomeAuthority, type BankerIncomeCollectionAuthority, type BankerIncomeContext, type BankerIncomeReceipt, type BankerIncomeState, } from '../game/spice-banker-income'; function context(turn = 1, phase = 3, owner = 'a'): BankerIncomeContext { return { turn, phase, players: [ { id: 'a', leaders: [{ id: 'emperor-0', dead: false }] }, { id: 'b', leaders: [{ id: 'guild-0', dead: false }] }, { id: 'c', leaders: [{ id: 'atreides-0', dead: false }] }, ], assignments: [{ skill: 'spice-banker', leader: owner === 'a' ? 'emperor-0' : 'guild-0', owner }], }; } function source(ctx: BankerIncomeContext, event = 'paid-auction', amount = 4, payer = 'b'): BankerIncomeAuthority { return { event, kind: 'auction', turn: ctx.turn, phase: ctx.phase, bankLegs: amount === 0 ? [] : [{ payer, amount }], trainer: { assignment: { ...ctx.assignments[0] }, faceUp: true, captured: false, selected: false, resolved: true, survived: true, }, }; } function earn(state: BankerIncomeState, ctx: BankerIncomeContext, authority: BankerIncomeAuthority): BankerIncomeState { const quote = quoteBankerIncome(state, ctx, authority); return commitBankerIncome(state, ctx, authority, quote.receipt); } function immutableReject(state: BankerIncomeState, action: () => unknown): void { const before = JSON.stringify(state); assert.throws(action, BankerIncomeError); assert.equal(JSON.stringify(state), before); } const opening: BankerIncomeCollectionAuthority = { event: 'native-mentat-1', turn: 1, phase: 8 }; void test('another payer must actually pay at least four in one final BANK leg; gain is additional deferred one', () => { const ctx = context(); for (const [payer, amount, expected] of [['b', 3, 0], ['b', 4, 1], ['b', 19, 1], ['a', 4, 0], ['b', 0, 0]] as const) { const initial = createBankerIncomeState(ctx); const authority = source(ctx, `${payer}-${amount}`, amount, payer); const before = JSON.stringify({ initial, authority, ctx }); const quoted = quoteBankerIncome(initial, ctx, authority); assert.equal(JSON.stringify({ initial, authority, ctx }), before); assert.equal(quoted.receipt.grant?.amount ?? 0, expected); assert.deepEqual(projectBankerIncome(quoted.next, ctx), { deferred: expected ? [{ owner: 'a', amount: 1 }] : [], usedThisPhase: expected ? ['a'] : [], }); assert.deepEqual(quoted.receipt.source.bankLegs, authority.bankLegs); assert.deepEqual(commitBankerIncome(initial, ctx, authority, quoted.receipt), quoted.next); } }); void test('separate native payments and different donor payers never aggregate to a threshold', () => { const ctx = context(); let state = createBankerIncomeState(ctx); for (const event of ['first-two', 'second-two']) state = earn(state, ctx, source(ctx, event, 2)); const split = source(ctx, 'different-payers'); split.bankLegs = [{ payer: 'b', amount: 2 }, { payer: 'c', amount: 2 }]; state = earn(state, ctx, split); assert.deepEqual(projectBankerIncome(state, ctx).deferred, []); const ownAndDonor = source(ctx, 'own-plus-donor'); ownAndDonor.bankLegs = [{ payer: 'a', amount: 8 }, { payer: 'c', amount: 4 }]; state = earn(state, ctx, ownAndDonor); assert.deepEqual(projectBankerIncome(state, ctx).deferred, [{ owner: 'a', amount: 1 }]); const duplicate = source(ctx, 'duplicate-donor'); duplicate.bankLegs = [{ payer: 'b', amount: 2 }, { payer: 'b', amount: 2 }]; immutableReject(state, () => quoteBankerIncome(state, ctx, duplicate)); }); void test('physical card once-phase stamp survives death, absence, capture and same-phase reassignment', () => { const ctx = context(); let state = earn(createBankerIncomeState(ctx), ctx, source(ctx)); const dead = context(); dead.players[0].leaders[0].dead = true; dead.assignments = []; const unavailable = { ...source(ctx, 'no-card'), trainer: null }; state = earn(state, dead, unavailable); assert.deepEqual(projectBankerIncome(state, dead), { deferred: [{ owner: 'a', amount: 1 }], usedThisPhase: ['a'] }); const transferred = context(1, 3, 'b'); state = earn(state, transferred, source(transferred, 'reassigned-this-phase', 6, 'c')); assert.equal(state.sources[2].grant, null); assert.deepEqual(projectBankerIncome(state, transferred).usedThisPhase, ['a']); const nextPhase = context(1, 4, 'b'); const revival = { ...source(nextPhase, 'next-phase', 4, 'c'), kind: 'leader-revival' as const }; state = earn(state, nextPhase, revival); assert.deepEqual(projectBankerIncome(state, nextPhase), { deferred: [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 1 }], usedThisPhase: ['b'], }); const nextTurn = context(2, 3, 'b'); state = earn(state, nextTurn, source(nextTurn, 'next-turn', 4, 'c')); assert.deepEqual(projectBankerIncome(state, nextTurn).deferred, [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 2 }]); }); void test('fresh eligibility requires actual native living assignment and not a captive normal band', () => { const ctx = context(); const state = createBankerIncomeState(ctx); const dead = context(); dead.players[0].leaders[0].dead = true; immutableReject(state, () => quoteBankerIncome(state, dead, source(ctx))); const absent = context(); absent.assignments = []; immutableReject(state, () => quoteBankerIncome(state, absent, source(ctx))); const foreign = source(ctx); foreign.trainer!.assignment.leader = 'guild-0'; immutableReject(state, () => quoteBankerIncome(state, ctx, foreign)); const captured = context(); captured.players[0].leaders[0].capturedBy = 'b'; const captive = source(captured); captive.trainer!.captured = true; const quoted = quoteBankerIncome(state, captured, captive); assert.equal(quoted.receipt.grant, null); captive.trainer!.captured = false; immutableReject(state, () => quoteBankerIncome(state, captured, captive)); }); void test('concealed battle trainer needs actual revealed selection and resolved survival, not private plan timing', () => { const ctx = context(1, 6); for (const [faceUp, selected, resolved, survived, expected] of [ [true, false, true, true, 1], [true, false, false, true, 0], [true, false, true, false, 0], [false, false, true, true, 0], [false, true, false, true, 0], [false, true, true, false, 0], [false, true, true, true, 1], ] as const) { const authority = { ...source(ctx, 'resolved-support'), kind: 'battle-support' as const }; Object.assign(authority.trainer!, { faceUp, selected, resolved, survived }); const quote = quoteBankerIncome(createBankerIncomeState(ctx), ctx, authority); assert.equal(quote.receipt.grant?.amount ?? 0, expected); } const impossible = { ...source(ctx), kind: 'battle-support' as const }; impossible.trainer!.selected = true; assert.throws(() => validateBankerIncomeSource(ctx, impossible), BankerIncomeError); }); void test('all named native producer kinds are phase-bound and empty bank settlement never triggers', () => { const kinds = [ ['auction', 3], ['shipment', 5], ['force-revival', 4], ['leader-revival', 4], ['kh-revival', 4], ['emperor-extra-revival', 4], ['ambassador-placement', 4], ['kaitain-disposal', 3], ['battle-support', 6], ] as const; for (const [kind, phase] of kinds) { const ctx = context(1, phase); const authority = { ...source(ctx, `native-${kind}`), kind }; assert.equal(quoteBankerIncome(createBankerIncomeState(ctx), ctx, authority).receipt.grant?.amount, 1); authority.bankLegs = []; assert.equal(quoteBankerIncome(createBankerIncomeState(ctx), ctx, authority).receipt.grant, null); authority.phase = phase === 3 ? 4 : 3; assert.throws(() => validateBankerIncomeSource(ctx, authority), BankerIncomeError); } }); void test('Ambassador placement and Kaitain disposal require one atomic four-spice other-payer BANK leg', () => { for (const [kind, phase] of [['ambassador-placement', 4], ['kaitain-disposal', 3]] as const) { const ctx = context(1, phase); let state = createBankerIncomeState(ctx); for (const event of ['first-two', 'second-two']) { state = earn(state, ctx, { ...source(ctx, event, 2), kind }); } assert.deepEqual(projectBankerIncome(state, ctx), { deferred: [], usedThisPhase: [] }); const split = { ...source(ctx, 'two-other-payers'), kind }; split.bankLegs = [{ payer: 'b', amount: 2 }, { payer: 'c', amount: 2 }]; state = earn(state, ctx, split); assert.equal(state.sources[2].grant, null); state = earn(state, ctx, { ...source(ctx, 'atomic-four'), kind }); state = earn(state, ctx, { ...source(ctx, 'later-five', 5), kind }); assert.equal(state.sources[3].grant?.amount, 1); assert.equal(state.sources[4].grant, null); assert.deepEqual(projectBankerIncome(state, ctx), { deferred: [{ owner: 'a', amount: 1 }], usedThisPhase: ['a'], }); } }); void test('new native BANK sources do not pay the owner, an unrevealed trainer or a captive normal band', () => { for (const [kind, phase] of [['ambassador-placement', 4], ['kaitain-disposal', 3]] as const) { const ctx = context(1, phase); const state = createBankerIncomeState(ctx); const self = { ...source(ctx, 'own-bank-payment', 4, 'a'), kind }; const noBankPayment = { ...source(ctx, 'no-bank-leg', 0), kind }; const noTrainer = { ...source(ctx, 'no-trainer'), kind, trainer: null }; const unrevealed = { ...source(ctx, 'unrevealed-trainer'), kind }; unrevealed.trainer!.faceUp = false; for (const authority of [self, noBankPayment, noTrainer, unrevealed]) { assert.equal(quoteBankerIncome(state, ctx, authority).receipt.grant, null); } const captiveContext = context(1, phase); captiveContext.players[0].leaders[0].capturedBy = 'b'; const captive = { ...source(captiveContext, 'captive-trainer'), kind }; captive.trainer!.captured = true; assert.equal(quoteBankerIncome(state, captiveContext, captive).receipt.grant, null); const absentContext = context(1, phase); absentContext.assignments = []; immutableReject(state, () => quoteBankerIncome(state, absentContext, { ...source(ctx), kind })); } }); void test('Kaitain, new Ecaz placement and shipment earn once per phase into the same deferred Mentat pile', () => { const auctionContext = context(1, 3); let state = earn(createBankerIncomeState(auctionContext), auctionContext, { ...source(auctionContext, 'kaitain-four'), kind: 'kaitain-disposal' }); state = earn(state, auctionContext, source(auctionContext, 'later-auction-five', 5)); assert.equal(state.sources[1].grant, null); const revivalContext = context(1, 4); state = earn(state, revivalContext, { ...source(revivalContext, 'ecaz-placement-four'), kind: 'ambassador-placement' }); state = earn(state, revivalContext, { ...source(revivalContext, 'later-leader-revival-five', 5), kind: 'leader-revival' }); assert.equal(state.sources[3].grant, null); const movementContext = context(1, 5); for (const event of ['first-shipment-five', 'later-shipment-five']) { state = earn(state, movementContext, { ...source(movementContext, event, 5), kind: 'shipment' }); } assert.equal(state.sources[5].grant, null); assert.equal(state.version, 1); assert.deepEqual(projectBankerIncome(state, movementContext), { deferred: [{ owner: 'a', amount: 3 }], usedThisPhase: ['a'], }); immutableReject(state, () => quoteBankerIncomeCollection(state, movementContext, opening)); const mentat = context(1, 8); const restored = JSON.parse(JSON.stringify(state)) as BankerIncomeState; validateBankerIncomeState(restored, mentat); const collection = quoteBankerIncomeCollection(restored, mentat, opening); assert.deepEqual(collection.credits, [{ owner: 'a', amount: 3 }]); assert.deepEqual(collection.collection.grants, [1, 3, 5]); assert.deepEqual(projectBankerIncome(collection.next, mentat), { deferred: [], usedThisPhase: [] }); }); void test('native Mentat atomically collects every original-owner grant once despite trainer/card changes', () => { const ctx = context(); let state = earn(createBankerIncomeState(ctx), ctx, source(ctx)); const revivalContext = context(1, 4, 'b'); state = earn(state, revivalContext, { ...source(revivalContext, 'revival', 4, 'c'), kind: 'force-revival' }); const mentat = context(1, 8); mentat.players[0].leaders[0].dead = true; mentat.players[1].leaders[0].capturedBy = 'c'; mentat.assignments = []; const before = JSON.stringify(state); assert.deepEqual(projectBankerIncome(state, mentat).deferred, [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 1 }]); const quoted = quoteBankerIncomeCollection(state, mentat, opening); assert.equal(JSON.stringify(state), before); assert.deepEqual(quoted.credits, [{ owner: 'a', amount: 1 }, { owner: 'b', amount: 1 }]); assert.deepEqual(quoted.collection.grants, [1, 2]); state = commitBankerIncomeCollection(state, mentat, opening, quoted.collection); assert.deepEqual(projectBankerIncome(state, mentat), { deferred: [], usedThisPhase: [] }); immutableReject(state, () => quoteBankerIncomeCollection(state, mentat, opening)); immutableReject(state, () => quoteBankerIncomeCollection(state, mentat, { ...opening, event: 'different-opening-id' })); const later = context(2, 3, 'b'); later.players[0].leaders[0].capturedBy = 'b'; assert.deepEqual(projectBankerIncome(JSON.parse(JSON.stringify(state)) as BankerIncomeState, later), { deferred: [], usedThisPhase: [] }); state = earn(state, later, source(later, 'new-turn-payment', 4, 'c')); const second = { event: 'native-mentat-2', turn: 2, phase: 8 as const }; const nextOpening = quoteBankerIncomeCollection(state, context(2, 8, 'b'), second); assert.deepEqual(nextOpening.credits, [{ owner: 'b', amount: 1 }]); }); void test('collection cannot happen early, change owner/amount, omit pending grants, or commit an old head', () => { const ctx = context(); const state = earn(createBankerIncomeState(ctx), ctx, source(ctx)); immutableReject(state, () => quoteBankerIncomeCollection(state, ctx, opening)); const mentat = context(1, 8); const quote = quoteBankerIncomeCollection(state, mentat, opening); for (const corrupt of [ { ...quote.collection, grants: [] }, { ...quote.collection, credits: [{ owner: 'b', amount: 1 }] }, { ...quote.collection, credits: [{ owner: 'a', amount: 2 }] }, { ...quote.collection, event: 'foreign-opening' }, { ...quote.collection, previous: '0000000000000000' }, ]) immutableReject(state, () => commitBankerIncomeCollection(state, mentat, opening, corrupt)); const fresh = { ...source(context(1, 4), 'extra-revival'), kind: 'leader-revival' as const }; const advanced = earn(state, context(1, 4), fresh); immutableReject(advanced, () => commitBankerIncomeCollection(advanced, mentat, opening, quote.collection)); }); void test('native authority is independent of stored receipt, and committed source cannot be replayed', () => { const ctx = context(); const state = createBankerIncomeState(ctx); const authority = source(ctx); const quote = quoteBankerIncome(state, ctx, authority); const changedAuthority = source(ctx, authority.event, 3); immutableReject(state, () => commitBankerIncome(state, ctx, changedAuthority, quote.receipt)); const changedReceipt = structuredClone(quote.receipt); changedReceipt.source.bankLegs[0].amount = 5; immutableReject(state, () => commitBankerIncome(state, ctx, authority, changedReceipt)); const committed = commitBankerIncome(state, ctx, authority, quote.receipt); immutableReject(committed, () => commitBankerIncome(committed, ctx, authority, quote.receipt)); immutableReject(committed, () => quoteBankerIncome(committed, context(2, 3), authority)); quote.receipt.source.bankLegs[0].amount = 90; authority.bankLegs[0].amount = 100; assert.equal(committed.sources[0].source.bankLegs[0].amount, 4); }); void test('malformed payments, foreign payers, extra fields and unsupported sources reject without mutation', () => { const ctx = context(); const state = createBankerIncomeState(ctx); const valid = source(ctx); const invalid: unknown[] = [null, {}, { ...valid, kind: 'gift' }, { ...valid, recipient: 'bank' }, { ...valid, turn: 0 }, { ...valid, turn: 2 }, { ...valid, event: '' }, ...[-1, 0, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1].map((amount) => ({ ...valid, bankLegs: [{ payer: 'b', amount }] })), { ...valid, bankLegs: [{ payer: 'bank', amount: 4 }] }, { ...valid, bankLegs: [{ payer: 'b', amount: 4, wallet: 99 }] }, { ...valid, trainer: { ...valid.trainer, survived: 'yes' } }, ]; for (const value of invalid) immutableReject(state, () => quoteBankerIncome(state, ctx, value as BankerIncomeAuthority)); }); void test('strict historical signatures, grant entitlement, cursor and collection custody reject corrupted JSON', () => { const ctx = context(); const earned = earn(createBankerIncomeState(ctx), ctx, source(ctx)); const mentat = context(1, 8); const collected = quoteBankerIncomeCollection(earned, mentat, opening).next; const changedPayer = structuredClone(earned); changedPayer.sources[0].source.bankLegs[0].payer = 'a'; const changedOwner = structuredClone(earned); changedOwner.sources[0].grant!.owner = 'b'; const alteredSource = structuredClone(earned); alteredSource.sources[0].source.bankLegs[0].amount = 3; const duplicateSource = structuredClone(earned); duplicateSource.sources.push(structuredClone(duplicateSource.sources[0])); duplicateSource.cursor++; const alteredCollection = structuredClone(collected); alteredCollection.collections[0].credits[0].owner = 'b'; const missingCollection = structuredClone(collected); missingCollection.collections[0].grants = []; const invalid: unknown[] = [null, { ...earned, extra: true }, { ...earned, head: '0000000000000000' }, { ...earned, signature: '0000000000000000' }, { ...earned, cursor: 0 }, { ...earned, seats: ['a', 'b', 'foreign'] }, changedPayer, changedOwner, alteredSource, duplicateSource, alteredCollection, missingCollection, { ...earned, sources: [{ ...earned.sources[0], grant: { ...earned.sources[0].grant, amount: 2 } }] }, { ...earned, sources: [{ ...earned.sources[0], extra: true }] }, ]; for (const value of invalid) assert.throws(() => validateBankerIncomeState(value as BankerIncomeState, mentat), BankerIncomeError); }); void test('public projection exposes only shield custody and used owner, never exact hidden source/trainer/payment', () => { const ctx = context(); const authority = source(ctx, 'private-native-event-id', 17, 'c'); const state = earn(createBankerIncomeState(ctx), ctx, authority); const before = JSON.stringify(state); const publicView = projectBankerIncome(state, ctx); assert.deepEqual(publicView, { deferred: [{ owner: 'a', amount: 1 }], usedThisPhase: ['a'] }); publicView.deferred[0].amount = 90; publicView.usedThisPhase[0] = 'b'; assert.equal(JSON.stringify(state), before); const receipt: BankerIncomeReceipt = state.sources[0]; assert.equal(receipt.source.bankLegs[0].amount, 17); assert.equal(receipt.grant!.owner, 'a'); });