Files
Dune/game/coalition-funding.ts
T
Quintine e529cc2d7a
Verify and publish checkpoint container / container (push) Successful in 11m0s
Extend Truthtrance bidding and prepare alliance controls
2026-10-28 22:06:55 +11:00

154 lines
8.4 KiB
TypeScript

import { coalitionGroups, type CoalitionSeat, type CoalitionState } from './coalition-alliance';
/** Already-escrowed spice, explicitly pledged by one donor to one ally.
* Wallet debits, pledge authorization, commitments and refunds remain the
* engine's responsibility; this ledger does not create or hold spice itself. */
export type CoalitionPledge = Readonly<{
donor: string;
recipient: string;
amount: number;
/** The particular alliance edge under which this escrow was authorized. */
epoch: string;
}>;
export type CoalitionFundingAuthorization = Readonly<{
/** Sorted pair of currently allied seats. */
members: readonly [string, string];
epoch: string;
}>;
export type CoalitionContribution = Readonly<{ donor: string; amount: number }>;
export type CoalitionFundingInput = Readonly<{
seats: readonly CoalitionSeat[];
coalitionState?: CoalitionState;
purpose: 'bid' | 'shipment';
payer: string;
cost: number;
/** Spendable payer spice after other obligations have been reserved. */
ownAvailable: number;
/** Authoritative current edge authorizations, never supplied by a client. */
authorizations: readonly CoalitionFundingAuthorization[];
pledges: readonly CoalitionPledge[];
contributions: readonly CoalitionContribution[];
}>;
export type CoalitionFundingQuote = {
purpose: 'bid' | 'shipment';
payer: string;
cost: number;
own: number;
contributions: { donor: string; amount: number }[];
};
export class CoalitionFundingError extends Error {
constructor(message: string) { super(message); this.name = 'CoalitionFundingError'; }
}
function requireFunding(condition: unknown, message: string): asserts condition {
if (!condition) throw new CoalitionFundingError(message);
}
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const amount = (value: unknown): value is number =>
Number.isSafeInteger(value) && Number(value) >= 0;
const pairKey = (donor: string, recipient: string) => JSON.stringify([donor, recipient]);
const edgeKey = (a: string, b: string) => JSON.stringify([a, b].sort());
const epoch = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.length <= 256 && value.trim() === value;
/** The engine must mint a globally fresh, never-reused epoch on edge formation,
* preserve it while that edge remains allied, and revoke it atomically on a
* break. A re-formed edge must receive a different epoch even in the same turn.
* Legacy pairs need an issued epoch before funding may use this API. */
function fundingEdges(input: Pick<CoalitionFundingInput, 'seats' | 'coalitionState' | 'authorizations'>) {
const groups = coalitionGroups(input.seats, input.coalitionState);
requireFunding(Array.isArray(input.authorizations), 'Supply current authoritative alliance funding epochs.');
const expected = new Set<string>();
for (const group of groups) for (const edge of group.edges)
expected.add(edgeKey(edge.members[0], edge.members[1]));
const current = new Map<string, string>(), usedEpochs = new Set<string>();
for (const authorization of input.authorizations) {
requireFunding(record(authorization) && Array.isArray(authorization.members) &&
authorization.members.length === 2 && typeof authorization.members[0] === 'string' &&
typeof authorization.members[1] === 'string' && authorization.members[0] < authorization.members[1] &&
epoch(authorization.epoch), 'Each funding epoch needs an ordered pair of allies.');
const key = edgeKey(authorization.members[0], authorization.members[1]);
requireFunding(expected.has(key) && !current.has(key) && !usedEpochs.has(authorization.epoch),
'Funding epochs must uniquely match current alliance edges exactly.');
current.set(key, authorization.epoch);
usedEpochs.add(authorization.epoch);
}
requireFunding(current.size === expected.size, 'Every current alliance edge needs an authoritative funding epoch.');
return current;
}
/** Separate stale escrow for refund after a break or epoch replacement.
* The engine must resolve or cancel any affected committed payments and
* atomically refund every returned stale record before using the retained
* ledger. No wallet or pledge is changed by this pure function. */
export function reconcileCoalitionPledges(input: Pick<CoalitionFundingInput,
'seats' | 'coalitionState' | 'authorizations' | 'pledges'>): {
pledges: CoalitionPledge[]; refunds: CoalitionPledge[];
} {
requireFunding(record(input) && Array.isArray(input.pledges), 'Supply the complete pledged escrow ledger.');
const current = fundingEdges(input);
const seated = new Set(input.seats.map(seat => seat.id));
const seen = new Set<string>(), pledges: CoalitionPledge[] = [], refunds: CoalitionPledge[] = [];
for (const pledge of input.pledges) {
requireFunding(record(pledge) && typeof pledge.donor === 'string' && typeof pledge.recipient === 'string' &&
pledge.donor !== pledge.recipient && seated.has(pledge.donor) && seated.has(pledge.recipient) &&
amount(pledge.amount) && epoch(pledge.epoch), 'Every pledge needs seated parties, safe escrow and an epoch.');
const key = pairKey(pledge.donor, pledge.recipient);
requireFunding(!seen.has(key), 'Each donor may have only one pledge for a recipient.');
seen.add(key);
(current.get(edgeKey(pledge.donor, pledge.recipient)) === pledge.epoch ? pledges : refunds).push({
donor: pledge.donor, recipient: pledge.recipient, amount: pledge.amount, epoch: pledge.epoch,
});
}
return { pledges, refunds };
}
/** Validate an exact declared split against current coalition membership and
* each recipient-specific escrow. The group-funding and allocation procedure
* is an online interpretation; the printed rule permits allies to help pay.
* This is not an authorization, reservation, or a promise of future funds. */
export function quoteCoalitionFunding(input: CoalitionFundingInput): CoalitionFundingQuote {
requireFunding(record(input) && (input.purpose === 'bid' || input.purpose === 'shipment') &&
amount(input.cost) && amount(input.ownAvailable) && Array.isArray(input.pledges) &&
Array.isArray(input.contributions), 'Choose a valid bid or shipment and safe spice amounts.');
requireFunding(typeof input.payer === 'string' && input.seats.some(seat => seat.id === input.payer),
'Choose a seated payer.');
const reconciled = reconcileCoalitionPledges(input);
requireFunding(reconciled.refunds.length === 0, 'Reconcile and refund stale pledge escrow before payment.');
const pledges = new Map<string, CoalitionPledge>();
for (const pledge of reconciled.pledges) pledges.set(pairKey(pledge.donor, pledge.recipient), pledge);
const donors = new Set<string>();
const contributions: { donor: string; amount: number }[] = [];
let own = input.cost;
for (const share of input.contributions) {
requireFunding(record(share) && typeof share.donor === 'string' && amount(share.amount) && share.amount > 0 &&
!donors.has(share.donor), 'Declare each donating ally once with a positive safe amount.');
const pledge = pledges.get(pairKey(share.donor, input.payer));
requireFunding(pledge && share.amount <= pledge.amount,
'Each contribution needs enough current escrow pledged to this payer.');
requireFunding(share.amount <= own, 'Contributions cannot exceed the cost.');
own -= share.amount;
donors.add(share.donor);
contributions.push({ donor: share.donor, amount: share.amount });
}
requireFunding(own <= input.ownAvailable, 'The payer cannot cover the remaining cost.');
return { purpose: input.purpose, payer: input.payer, cost: input.cost, own, contributions };
}
/** Revalidate the complete current input at the moment of payment and return
* a new pledge ledger. The engine must debit quote.own and install this ledger
* in the same authoritative action; recipient routing and replay protection
* remain native transaction responsibilities. Never apply a stale quote alone. */
export function settleCoalitionFunding(input: CoalitionFundingInput): {
quote: CoalitionFundingQuote;
pledges: CoalitionPledge[];
} {
const quote = quoteCoalitionFunding(input);
const debits = new Map(quote.contributions.map(share => [share.donor, share.amount]));
const pledges = input.pledges.map(pledge => ({ ...pledge,
amount: pledge.amount - (pledge.recipient === quote.payer ? debits.get(pledge.donor) ?? 0 : 0),
}));
return { quote, pledges };
}