155 lines
8.2 KiB
TypeScript
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 };
|
|
}
|