Files
Dune/game/spice-banker-income.ts

386 lines
20 KiB
TypeScript

import { LEADER_SKILL_CARDS } from './leader-skill-cards';
import type { LeaderSkillAssignment, SkillRoster } from './leader-skills';
export type BankerIncomeSourceKind =
| 'auction'
| 'shipment'
| 'force-revival'
| 'leader-revival'
| 'kh-revival'
| 'emperor-extra-revival'
| 'ambassador-placement'
| 'kaitain-disposal'
| 'battle-support';
export type BankerIncomeContext = {
turn: number;
phase: number;
players: SkillRoster;
assignments: readonly LeaderSkillAssignment[];
};
export type BankerIncomeAuthority = {
kind: BankerIncomeSourceKind;
event: string;
turn: number;
phase: number;
/** Only positive, actually paid final BANK legs of this one native payment. */
bankLegs: { payer: string; amount: number }[];
trainer: {
assignment: LeaderSkillAssignment;
faceUp: boolean;
captured: boolean;
selected: boolean;
resolved: boolean;
survived: boolean;
} | null;
};
export type BankerIncomeGrant = { owner: string; amount: 1; stamp: string };
export type BankerIncomeReceipt = {
cursor: number;
previous: string;
source: BankerIncomeAuthority;
grant: BankerIncomeGrant | null;
signature: string;
};
export type BankerIncomeCredit = { owner: string; amount: number };
export type BankerIncomeCollectionAuthority = { event: string; turn: number; phase: 8 };
export type BankerIncomeCollection = BankerIncomeCollectionAuthority & {
cursor: number;
previous: string;
grants: number[];
credits: BankerIncomeCredit[];
signature: string;
};
export type BankerIncomeState = {
version: 1;
seats: string[];
sources: BankerIncomeReceipt[];
collections: BankerIncomeCollection[];
cursor: number;
head: string;
signature: string;
};
export class BankerIncomeError extends Error {
constructor(message: string) {
super(message);
this.name = 'BankerIncomeError';
}
}
const LIMIT = 50_000;
const sourcePhases: Record<BankerIncomeSourceKind, number> = {
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,
};
const text = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.length <= 512 && value.trim() === value;
const whole = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const array = (value: unknown): boolean => Array.isArray(value);
function requireIncome(condition: unknown, message: string): asserts condition {
if (!condition) throw new BankerIncomeError(message);
}
function keys(value: unknown, expected: readonly string[]): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
Object.keys(value).length === expected.length && expected.every((key) => Object.hasOwn(value, key));
}
/** Fixed-size consistency checksum, not authentication of a rewritten save. */
function signature(value: unknown): string {
const serialized = JSON.stringify(value);
let first = 2166136261;
let second = 3339675911;
for (let i = 0; i < serialized.length; i++) {
const code = serialized.charCodeAt(i);
first = Math.imul(first ^ code, 16777619);
second = Math.imul(second ^ code, 2246822519);
}
return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`;
}
const validSignature = (value: unknown): value is string =>
typeof value === 'string' && /^[0-9a-f]{16}$/.test(value);
function sourceFields(source: BankerIncomeAuthority): unknown[] {
const trainer = source.trainer;
return [source.kind, source.event, source.turn, source.phase,
source.bankLegs.map((leg) => [leg.payer, leg.amount]), trainer === null ? null : [
trainer.assignment.skill, trainer.assignment.leader, trainer.assignment.owner,
trainer.faceUp, trainer.captured, trainer.selected, trainer.resolved, trainer.survived,
]];
}
function receiptSignature(receipt: BankerIncomeReceipt): string {
return signature(['banker-income-source', receipt.cursor, receipt.previous,
sourceFields(receipt.source), receipt.grant === null ? null :
[receipt.grant.owner, receipt.grant.amount, receipt.grant.stamp]]);
}
function collectionSignature(collection: BankerIncomeCollection): string {
return signature(['banker-income-collection', collection.cursor, collection.previous,
collection.event, collection.turn, collection.phase, collection.grants,
collection.credits.map((credit) => [credit.owner, credit.amount])]);
}
function stateSignature(state: BankerIncomeState): string {
return signature(['banker-income-state', state.version, state.seats,
state.sources.length, state.collections.length, state.cursor, state.head]);
}
function stamp(turn: number, phase: number): string {
return JSON.stringify([turn, phase, 'spice-banker']);
}
function validateContext(context: BankerIncomeContext): string[] {
requireIncome(keys(context, ['turn', 'phase', 'players', 'assignments']) &&
whole(context.turn) && context.turn > 0 && whole(context.phase) && context.phase <= 8 &&
array(context.players) && context.players.length >= 2 && context.players.length <= 6 &&
array(context.assignments), 'Invalid Banker income native context.');
const seats: string[] = [];
const leaders = new Set<string>();
for (const player of context.players) {
requireIncome(keys(player, ['id', 'leaders']) && text(player.id) && array(player.leaders) &&
!seats.includes(player.id), 'Invalid Banker income native roster.');
seats.push(player.id);
for (const leader of player.leaders) {
requireIncome(leader && typeof leader === 'object' && !Array.isArray(leader) &&
Object.keys(leader).every((key) => ['id', 'dead', 'capturedBy', 'gholaBy'].includes(key)) &&
text(leader.id) && typeof leader.dead === 'boolean' && !leaders.has(leader.id) &&
(leader.capturedBy === undefined || text(leader.capturedBy)) &&
(leader.gholaBy === undefined || text(leader.gholaBy)), 'Invalid Banker income native leader.');
leaders.add(leader.id);
}
}
const skills = new Set<string>();
const owners = new Set<string>();
for (const assignment of context.assignments) {
requireIncome(keys(assignment, ['skill', 'leader', 'owner']) &&
LEADER_SKILL_CARDS.some((card) => card.id === assignment.skill) &&
!skills.has(assignment.skill) && !owners.has(assignment.owner) &&
context.players.some((player) => player.id === assignment.owner &&
player.leaders.some((leader) => leader.id === assignment.leader)),
'Invalid Banker income native skill assignment.');
skills.add(assignment.skill);
owners.add(assignment.owner);
}
return seats.sort();
}
function validateSource(context: BankerIncomeContext, source: BankerIncomeAuthority, current: boolean): void {
requireIncome(keys(source, ['kind', 'event', 'turn', 'phase', 'bankLegs', 'trainer']) &&
Object.hasOwn(sourcePhases, source.kind) && text(source.event) &&
whole(source.turn) && source.turn > 0 && whole(source.phase) &&
source.phase === sourcePhases[source.kind] &&
(source.turn < context.turn || (source.turn === context.turn && source.phase <= context.phase)) &&
(!current || (source.turn === context.turn && source.phase === context.phase)) &&
array(source.bankLegs) && source.bankLegs.length <= context.players.length,
'Invalid, unsupported or stale Banker income payment source.');
const payers = new Set<string>();
for (const leg of source.bankLegs) {
requireIncome(keys(leg, ['payer', 'amount']) && text(leg.payer) &&
context.players.some((player) => player.id === leg.payer) && !payers.has(leg.payer) &&
whole(leg.amount) && leg.amount > 0, 'Banker income requires distinct actual positive BANK payer legs.');
payers.add(leg.payer);
}
const trainer = source.trainer;
if (trainer === null) return;
requireIncome(keys(trainer, ['assignment', 'faceUp', 'captured', 'selected', 'resolved', 'survived']) &&
keys(trainer.assignment, ['skill', 'leader', 'owner']) &&
trainer.assignment.skill === 'spice-banker' && text(trainer.assignment.leader) &&
text(trainer.assignment.owner) &&
[trainer.faceUp, trainer.captured, trainer.selected, trainer.resolved, trainer.survived]
.every((value) => typeof value === 'boolean') &&
(!trainer.selected || (!trainer.faceUp && source.kind === 'battle-support')) &&
(source.kind === 'battle-support' || (!trainer.selected && trainer.resolved && trainer.survived)),
'Invalid Banker income trainer posture or resolved survival.');
const native = context.players.find((player) => player.id === trainer.assignment.owner)
?.leaders.find((leader) => leader.id === trainer.assignment.leader);
requireIncome(native, 'Banker income trainer is not its original native disc.');
if (current) {
requireIncome(!native.dead && !!native.capturedBy === trainer.captured &&
context.assignments.some((assignment) => assignment.skill === 'spice-banker' &&
assignment.owner === trainer.assignment.owner && assignment.leader === trainer.assignment.leader),
'Banker income source lacks the actual living native trainer and physical skill.');
}
}
export function validateBankerIncomeSource(context: BankerIncomeContext, source: BankerIncomeAuthority): void {
validateContext(context);
validateSource(context, source, true);
}
function expectedGrant(source: BankerIncomeAuthority, used: ReadonlySet<string>): BankerIncomeGrant | null {
const trainer = source.trainer;
const phaseStamp = stamp(source.turn, source.phase);
if (trainer === null || trainer.captured || !trainer.survived ||
(source.kind === 'battle-support' && !trainer.resolved) ||
(!trainer.faceUp && !(trainer.selected && trainer.resolved)) || used.has(phaseStamp) ||
!source.bankLegs.some((leg) => leg.payer !== trainer.assignment.owner && leg.amount >= 4)) return null;
return { owner: trainer.assignment.owner, amount: 1, stamp: phaseStamp };
}
function creditsFor(seats: readonly string[], pending: readonly BankerIncomeReceipt[]): BankerIncomeCredit[] {
return seats.flatMap((owner) => {
const amount = pending.reduce((total, receipt) => total + (receipt.grant?.owner === owner ? 1 : 0), 0);
return amount === 0 ? [] : [{ owner, amount }];
});
}
function sameCredits(left: readonly BankerIncomeCredit[], right: readonly BankerIncomeCredit[]): boolean {
return left.length === right.length && left.every((credit, index) =>
keys(credit, ['owner', 'amount']) && credit.owner === right[index].owner && credit.amount === right[index].amount);
}
/** Closed history checks original entitlement, never current life, card custody or wallet. */
export function validateBankerIncomeState(state: BankerIncomeState, context: BankerIncomeContext): void {
const seats = validateContext(context);
requireIncome(keys(state, ['version', 'seats', 'sources', 'collections', 'cursor', 'head', 'signature']) &&
state.version === 1 && array(state.seats) &&
state.seats.length === seats.length && state.seats.every((seat, index) => seat === seats[index]) &&
array(state.sources) && array(state.collections) && whole(state.cursor) &&
state.cursor === state.sources.length + state.collections.length && state.cursor <= LIMIT &&
validSignature(state.head) && validSignature(state.signature), 'Malformed Banker income history.');
let head = signature(['banker-income-genesis', seats]);
let sourceIndex = 0;
let collectionIndex = 0;
let lastTurn = 0;
let lastPhase = 0;
const events = new Set<string>();
const used = new Set<string>();
const collectedTurns = new Set<number>();
let pending: BankerIncomeReceipt[] = [];
for (let cursor = 1; cursor <= state.cursor; cursor++) {
const receipt = state.sources[sourceIndex];
const collection = state.collections[collectionIndex];
const isSource = receipt?.cursor === cursor;
requireIncome(isSource !== (collection?.cursor === cursor), 'Missing or duplicate Banker income history cursor.');
let event: string;
let turn: number;
let phase: number;
if (isSource) {
requireIncome(keys(receipt, ['cursor', 'previous', 'source', 'grant', 'signature']) &&
receipt.previous === head && validSignature(receipt.signature), 'Corrupt Banker income source head.');
validateSource(context, receipt.source, false);
const grant = expectedGrant(receipt.source, used);
requireIncome(grant === null ? receipt.grant === null :
keys(receipt.grant, ['owner', 'amount', 'stamp']) && receipt.grant.owner === grant.owner &&
receipt.grant.amount === 1 && receipt.grant.stamp === grant.stamp, 'Corrupt Banker income deferred grant.');
requireIncome(receipt.signature === receiptSignature(receipt), 'Corrupt Banker income source signature.');
if (grant !== null) {
used.add(grant.stamp);
pending.push(receipt);
}
({ event, turn, phase } = receipt.source);
head = receipt.signature;
sourceIndex++;
} else {
requireIncome(keys(collection, ['cursor', 'previous', 'event', 'turn', 'phase', 'grants', 'credits', 'signature']) &&
collection.previous === head && text(collection.event) && whole(collection.turn) && collection.turn > 0 &&
collection.phase === 8 && !collectedTurns.has(collection.turn) &&
(collection.turn < context.turn || (collection.turn === context.turn && context.phase === 8)) &&
Array.isArray(collection.grants) && collection.grants.length === pending.length &&
collection.grants.every((grant, index) => grant === pending[index].cursor) &&
Array.isArray(collection.credits) && sameCredits(collection.credits, creditsFor(seats, pending)) &&
validSignature(collection.signature) && collection.signature === collectionSignature(collection),
'Corrupt, incomplete or replayed Banker income collection.');
({ event, turn, phase } = collection);
collectedTurns.add(turn);
pending = [];
head = collection.signature;
collectionIndex++;
}
requireIncome(!events.has(event) && (turn > lastTurn || (turn === lastTurn && phase >= lastPhase)),
'Duplicate or out-of-order Banker income native event.');
events.add(event);
lastTurn = turn;
lastPhase = phase;
}
requireIncome(head === state.head && sourceIndex === state.sources.length &&
collectionIndex === state.collections.length && state.signature === stateSignature(state),
'Corrupt Banker income current head or signature.');
}
export function createBankerIncomeState(context: BankerIncomeContext): BankerIncomeState {
const seats = validateContext(context);
const state: BankerIncomeState = {
version: 1, seats, sources: [], collections: [], cursor: 0,
head: signature(['banker-income-genesis', seats]), signature: '',
};
state.signature = stateSignature(state);
return state;
}
function requireFreshEvent(state: BankerIncomeState, event: string): void {
requireIncome(state.cursor < LIMIT && !state.sources.some((receipt) => receipt.source.event === event) &&
!state.collections.some((collection) => collection.event === event),
'Banker income native event was already committed or history is full.');
}
export function quoteBankerIncome(state: BankerIncomeState, context: BankerIncomeContext, authority: BankerIncomeAuthority): {
next: BankerIncomeState; receipt: BankerIncomeReceipt;
} {
validateBankerIncomeState(state, context);
validateSource(context, authority, true);
requireFreshEvent(state, authority.event);
const used = new Set(state.sources.flatMap((receipt) => receipt.grant === null ? [] : [receipt.grant.stamp]));
const receipt: BankerIncomeReceipt = {
cursor: state.cursor + 1, previous: state.head, source: structuredClone(authority),
grant: expectedGrant(authority, used), signature: '',
};
receipt.signature = receiptSignature(receipt);
const next = structuredClone(state);
next.sources.push(receipt);
next.cursor = receipt.cursor;
next.head = receipt.signature;
next.signature = stateSignature(next);
return { next, receipt: structuredClone(receipt) };
}
/** Caller commits this ledger and original native payment/suffix in the same room write. */
export function commitBankerIncome(state: BankerIncomeState, context: BankerIncomeContext,
authority: BankerIncomeAuthority, receipt: BankerIncomeReceipt): BankerIncomeState {
const quote = quoteBankerIncome(state, context, authority);
requireIncome(keys(receipt, ['cursor', 'previous', 'source', 'grant', 'signature']), 'Malformed Banker income receipt.');
validateSource(context, receipt.source, true);
requireIncome(receipt.cursor === quote.receipt.cursor && receipt.previous === quote.receipt.previous &&
(quote.receipt.grant === null ? receipt.grant === null : keys(receipt.grant, ['owner', 'amount', 'stamp']) &&
receipt.grant.owner === quote.receipt.grant.owner && receipt.grant.amount === 1 &&
receipt.grant.stamp === quote.receipt.grant.stamp) &&
receipt.signature === quote.receipt.signature && receipt.signature === receiptSignature(receipt),
'Banker income receipt is foreign, stale or disagrees with independent native authority.');
return quote.next;
}
export function quoteBankerIncomeCollection(state: BankerIncomeState, context: BankerIncomeContext,
authority: BankerIncomeCollectionAuthority): {
next: BankerIncomeState; collection: BankerIncomeCollection; credits: BankerIncomeCredit[];
} {
validateBankerIncomeState(state, context);
requireIncome(keys(authority, ['event', 'turn', 'phase']) && text(authority.event) &&
authority.turn === context.turn && authority.phase === 8 && context.phase === 8,
'Banker income collects only at the actual current native Mentat opening.');
requireFreshEvent(state, authority.event);
requireIncome(!state.collections.some((collection) => collection.turn === authority.turn),
'Banker income already collected at this turn\'s Mentat opening.');
const collected = new Set(state.collections.flatMap((collection) => collection.grants));
const pending = state.sources.filter((receipt) => receipt.grant !== null && !collected.has(receipt.cursor));
const credits = creditsFor(state.seats, pending);
const collection: BankerIncomeCollection = {
...authority, cursor: state.cursor + 1, previous: state.head,
grants: pending.map((receipt) => receipt.cursor), credits, signature: '',
};
collection.signature = collectionSignature(collection);
const next = structuredClone(state);
next.collections.push(collection);
next.cursor = collection.cursor;
next.head = collection.signature;
next.signature = stateSignature(next);
return { next, collection: structuredClone(collection), credits: structuredClone(credits) };
}
export function commitBankerIncomeCollection(state: BankerIncomeState, context: BankerIncomeContext,
authority: BankerIncomeCollectionAuthority, collection: BankerIncomeCollection): BankerIncomeState {
const quote = quoteBankerIncomeCollection(state, context, authority);
requireIncome(keys(collection, ['cursor', 'previous', 'event', 'turn', 'phase', 'grants', 'credits', 'signature']) &&
collection.cursor === quote.collection.cursor && collection.previous === quote.collection.previous &&
collection.event === authority.event && collection.turn === authority.turn && collection.phase === 8 &&
Array.isArray(collection.grants) && collection.grants.length === quote.collection.grants.length &&
collection.grants.every((grant, index) => grant === quote.collection.grants[index]) &&
Array.isArray(collection.credits) && sameCredits(collection.credits, quote.credits) &&
collection.signature === quote.collection.signature && collection.signature === collectionSignature(collection),
'Banker income collection is foreign, stale, incomplete or corrupt.');
return quote.next;
}
export function projectBankerIncome(state: BankerIncomeState, context: BankerIncomeContext): {
deferred: BankerIncomeCredit[]; usedThisPhase: string[];
} {
validateBankerIncomeState(state, context);
const collected = new Set(state.collections.flatMap((collection) => collection.grants));
const pending = state.sources.filter((receipt) => receipt.grant !== null && !collected.has(receipt.cursor));
return {
deferred: creditsFor(state.seats, pending),
usedThisPhase: state.sources.flatMap((receipt) =>
receipt.grant?.stamp === stamp(context.turn, context.phase) ? [receipt.grant.owner] : []),
};
}