354 lines
19 KiB
TypeScript
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');
|
|
});
|