Files
Dune/game/public-deal-obligations.ts

94 lines
4.7 KiB
TypeScript

import { validatePublicDeals, voidPublicDealPayment, type PublicDeal, type PublicDealSeat } from './public-deals';
/** A deal-linked obligation is independent of every other deal and of
* Truthtrance. It is due once for its exact gross payment, not a threshold
* shared with a different agreement. */
export type PendingPublicDealPayment = {
deal: string;
payer: string;
recipient: string;
dueTurn: number;
amount: number;
};
export class PublicDealObligationError extends Error {
constructor(message: string) {
super(message);
this.name = 'PublicDealObligationError';
}
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const nonnegative = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
/** Return a payer's unsettled active terms that can still be paid now,
* including future deadlines. Past-due rows stay in the authoritative ledger
* for the engine's expiry reconciliation; this helper never voids them. */
export function outstandingPublicDealPayments(
deals: readonly PublicDeal[], payer: string, turn: number,
): PendingPublicDealPayment[] {
if (!Array.isArray(deals) || !identifier(payer) || !Number.isSafeInteger(turn) || turn < 1)
throw new PublicDealObligationError('Retain the payer and original turn of the deferred payments.');
return deals.flatMap(deal => {
const payment = deal.payment;
if (deal.status !== 'active' || deal.paymentStatus !== 'pending' ||
payment?.kind !== 'deferredBribe' || payment.payer !== payer || payment.dueTurn < turn) return [];
return [{ deal: deal.id, payer, recipient: payment.recipient, dueTurn: payment.dueTurn, amount: payment.amount }];
});
}
/** Existing turn-specific planners and funding totals consider only payments
* due in that turn. They must not reserve every future promise immediately. */
export function pendingPublicDealPayments(
deals: readonly PublicDeal[], payer: string, turn: number,
): PendingPublicDealPayment[] {
return outstandingPublicDealPayments(deals,payer,turn).filter(payment => payment.dueTurn === turn);
}
/** Separate deals add, including two deals for the same recipient. */
export function publicDealPaymentTotal(deals: readonly PublicDeal[], payer: string, turn: number): number {
let total = 0;
for (const payment of pendingPublicDealPayments(deals, payer, turn)) {
if (!Number.isSafeInteger(payment.amount) || payment.amount <= 0 ||
!Number.isSafeInteger(total + payment.amount))
throw new PublicDealObligationError('The outstanding deal payments exceed the safe spice range.');
total += payment.amount;
}
return total;
}
export type PublicDealPaymentWitness = {
/** The engine's currently available uncommitted funding, or null if a
* pending continuation means no reliable current quote is available. */
available: number | null;
change: 'voluntary' | 'external';
/** Each ID must be proven individually impossible by the engine. A mere
* cash shortage does not prove loss of future income or the other debts. */
impossibleDealIds?: readonly string[];
};
/** An actor may not voluntarily make their accepted, funded payments
* impossible. External uncertainty leaves every agreement intact; only
* independently proven impossible terms are voided. No funds move here. */
export function reconcilePublicDealPayments(
deals: readonly PublicDeal[], players: readonly PublicDealSeat[], payer: string, turn: number,
witness: PublicDealPaymentWitness,
): PublicDeal[] {
validatePublicDeals(deals, players);
if (!witness || (witness.change !== 'voluntary' && witness.change !== 'external') ||
(witness.available !== null && !nonnegative(witness.available)) ||
(witness.impossibleDealIds !== undefined && !Array.isArray(witness.impossibleDealIds)))
throw new PublicDealObligationError('Retain the actual funded witness and cause of the change.');
const pending = pendingPublicDealPayments(deals, payer, turn);
const ids = witness.impossibleDealIds ?? [];
if (new Set(ids).size !== ids.length || ids.some(id => !pending.some(row => row.deal === id)))
throw new PublicDealObligationError('Void only individually proven pending terms of this payer and turn.');
if (witness.change === 'voluntary' && (ids.length > 0 ||
witness.available !== null && witness.available < publicDealPaymentTotal(deals, payer, turn)))
throw new PublicDealObligationError('Keep the funding needed for your accepted public deal payments.');
if (witness.change !== 'external' || !ids.length) return [...deals];
return deals.map(deal => ids.includes(deal.id) ? voidPublicDealPayment(deal, players) : deal);
}