Files
Dune/tests/spice-banker-income.test.ts
T

354 lines
19 KiB
TypeScript

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