Files
Dune/game/coalition-escrow.ts
Quintine 34aa64cdef
Verify and publish checkpoint container / container (push) Canceled after 0s
Support staged group shipment funding
2026-10-28 23:14:44 +11:00

155 lines
8.2 KiB
TypeScript

import { coalitionGroups, type CoalitionSeat, type CoalitionState } from './coalition-alliance';
import {
CoalitionFundingError,
reconcileCoalitionPledges,
type CoalitionFundingAuthorization,
type CoalitionPledge,
} from './coalition-funding';
/** Epochs are never reused, including after an edge leaves and reforms. */
export type CoalitionEscrowState = Readonly<{
authorizations: readonly CoalitionFundingAuthorization[];
pledges: readonly CoalitionPledge[];
usedEpochs: readonly string[];
}>;
export type CoalitionEscrowMembership = Readonly<{
seats: readonly CoalitionSeat[];
coalitionState?: CoalitionState;
}>;
export type CoalitionEscrowRefund = Readonly<{ donor: string; amount: number }>;
const whole = (n: unknown): n is number => Number.isSafeInteger(n) && (n as number) >= 0;
const validEpoch = (s: unknown): s is string => typeof s === 'string' && !!s &&
s.length <= 256 && s.trim() === s;
function check(condition: unknown, message: string): asserts condition {
if (!condition) throw new CoalitionFundingError(message);
}
const key = (members: readonly string[]) => JSON.stringify(members);
const edge = (a: string, b: string) => key([a, b].sort());
const directed = (donor: string, recipient: string) => key([donor, recipient]);
function checkedSum(a: number, b: number): number {
const result = a + b;
check(whole(result), 'Escrow totals must be safe integers.');
return result;
}
function edges(membership: CoalitionEscrowMembership): Map<string, readonly [string, string]> {
const out = new Map<string, readonly [string, string]>();
for (const group of coalitionGroups(membership.seats, membership.coalitionState))
for (const item of group.edges) out.set(key(item.members), item.members);
return out;
}
function validateHistory(escrow: CoalitionEscrowState) {
check(!!escrow && Array.isArray(escrow.authorizations) && Array.isArray(escrow.pledges) &&
Array.isArray(escrow.usedEpochs) && escrow.usedEpochs.every(validEpoch) &&
new Set(escrow.usedEpochs).size === escrow.usedEpochs.length,
'The escrow must retain unique valid epoch history.');
const used = new Set(escrow.usedEpochs);
check(escrow.authorizations.every(auth => auth && validEpoch(auth.epoch) && used.has(auth.epoch)) &&
escrow.pledges.every(pledge => pledge && validEpoch(pledge.epoch) && used.has(pledge.epoch)),
'Every escrow epoch must appear in its retained history.');
}
function validateCurrent(membership: CoalitionEscrowMembership, escrow: CoalitionEscrowState) {
validateHistory(escrow);
const result = reconcileCoalitionPledges({ ...membership,
authorizations: escrow.authorizations, pledges: escrow.pledges });
check(result.refunds.length === 0, 'Reconcile stale pledges before changing escrow.');
}
/**
* Apply one authoritative alliance transition. Call once for each formation or
* break, not on a collapsed before/after history: an edge broken and reformed
* must receive a new epoch. The caller supplies freshly generated IDs for only
* the new edges, atomically installs the result and credits each donor refund.
* A pledge with a live commitment cannot be refunded until that commitment
* has been settled or canceled. The caller supplies every such locked pledge.
*/
export function reconcileCoalitionEscrow(input: Readonly<{
before: CoalitionEscrowMembership;
after: CoalitionEscrowMembership;
escrow: CoalitionEscrowState;
newAuthorizations: readonly CoalitionFundingAuthorization[];
locked?: readonly CoalitionPledge[];
}>): { escrow: CoalitionEscrowState; refunds: CoalitionEscrowRefund[] } {
const { before, after, escrow } = input;
validateCurrent(before, escrow);
const oldEdges = edges(before), newEdges = edges(after);
const beforeIds = before.seats.map(seat => seat.id).sort();
const afterIds = after.seats.map(seat => seat.id).sort();
check(key(beforeIds) === key(afterIds), 'Escrow transitions require the same seated players.');
check(Array.isArray(input.newAuthorizations) && Array.isArray(input.locked ?? []),
'Supply new edge authorizations and any locked pledges.');
const previous = new Map(escrow.authorizations.map(auth => [key(auth.members), auth]));
const history = new Set(escrow.usedEpochs);
const fresh = new Map<string, CoalitionFundingAuthorization>();
for (const auth of input.newAuthorizations) {
check(!!auth && Array.isArray(auth.members) && auth.members.length === 2 &&
typeof auth.members[0] === 'string' && typeof auth.members[1] === 'string' &&
auth.members[0] < auth.members[1] && validEpoch(auth.epoch),
'Each fresh epoch needs an ordered pair of allies.');
const id = key(auth.members);
check(newEdges.has(id) && !oldEdges.has(id) && !fresh.has(id) && !history.has(auth.epoch),
'Fresh epochs must uniquely authorize new edges and cannot be reused.');
history.add(auth.epoch);
fresh.set(id, { members: [auth.members[0], auth.members[1]], epoch: auth.epoch });
}
const authorizations: CoalitionFundingAuthorization[] = [];
for (const [id, members] of newEdges) {
const auth = oldEdges.has(id) ? previous.get(id) : fresh.get(id);
check(auth, 'Every new alliance edge needs a fresh epoch.');
authorizations.push({ members: [members[0], members[1]], epoch: auth.epoch });
}
const pledged = new Map(escrow.pledges.map(item => [directed(item.donor, item.recipient), item]));
const locked = new Set<string>();
for (const item of input.locked ?? []) {
check(!!item && typeof item.donor === 'string' && typeof item.recipient === 'string' &&
whole(item.amount) && item.amount > 0 && validEpoch(item.epoch), 'A locked pledge needs a valid committed amount.');
const id = directed(item.donor, item.recipient), pledge = pledged.get(id);
check(!locked.has(id) && pledge && pledge.epoch === item.epoch && item.amount <= pledge.amount,
'Each committed pledge must match its available escrow once.');
locked.add(id);
check(newEdges.has(edge(item.donor, item.recipient)),
'Resolve committed payments before breaking their alliance edge.');
}
const reconciled = reconcileCoalitionPledges({ ...after, authorizations, pledges: escrow.pledges });
const totals = new Map<string, number>();
for (const item of reconciled.refunds)
totals.set(item.donor, checkedSum(totals.get(item.donor) ?? 0, item.amount));
return {
escrow: { authorizations, pledges: reconciled.pledges, usedEpochs: [...history] },
refunds: [...totals].map(([donor, amount]) => ({ donor, amount })),
};
}
/** Return a signed wallet change and replacement ledger without mutating the
* donor. `available` is uncommitted wallet spice; `locked` is the already
* committed amount of this recipient's existing pledge. The engine must
* verify the actual wallet and atomically apply both outputs. */
export function setCoalitionPledge(input: CoalitionEscrowMembership & Readonly<{
escrow: CoalitionEscrowState;
donor: string;
recipient: string;
amount: number;
available: number;
locked?: number;
}>): { escrow: CoalitionEscrowState; walletDelta: number } {
const { escrow, donor, recipient, amount, available } = input;
const locked = input.locked ?? 0;
validateCurrent(input, escrow);
check(typeof donor === 'string' && typeof recipient === 'string' && donor !== recipient &&
whole(amount) && whole(available) && whole(locked), 'Choose a valid donor, recipient and safe spice amounts.');
const auth = escrow.authorizations.find(item => key(item.members) === edge(donor, recipient));
check(auth, 'A pledge requires a current authorized alliance edge.');
const old = escrow.pledges.find(item => item.donor === donor && item.recipient === recipient);
const prior = old?.amount ?? 0;
check(locked <= prior && amount >= locked, 'Committed pledge spice cannot be withdrawn.');
const delta = prior - amount;
check(delta >= 0 || -delta <= available, 'The donor lacks uncommitted spice for this pledge.');
if (delta > 0) checkedSum(available, delta);
const pledges = escrow.pledges.filter(item => item.donor !== donor || item.recipient !== recipient)
.map(item => ({ ...item }));
if (amount) pledges.push({ donor, recipient, amount, epoch: auth.epoch });
return { escrow: { authorizations: escrow.authorizations.map(item => ({
members: [item.members[0], item.members[1]], epoch: item.epoch,
})), pledges, usedEpochs: [...escrow.usedEpochs] }, walletDelta: delta };
}