/** A future accepted update to the amount made available to an existing ally. * This is not an actual expenditure or a promise that the ally will use it. */ export type PledgeClaim = { turn: number; phase: 3 | 5; ally: string; minimum: number }; export type PledgePromise = PledgeClaim & { event: string; player: string; asker: string; answer: boolean; fulfilled?: boolean; released?: boolean; }; export type PledgeRequirement = { ally: string; minimum: number; maximum?: number }; export type PledgeUpdate = { ally: string; amount: number }; /** Only public question history determines whether a new question is offered. * Do not reveal whether a private compiled residual is still live. */ export function pledgeQuestionHistoryBlocked(history: readonly { turn: number; phase: number; answer: string; question: { kind: string; scope?: string }; }[], turn: number, phase: number): boolean { return history.some(record => record.turn === turn && record.answer !== 'unknown' && (record.question.kind === 'pledgeAid' ? record.phase === phase : record.question.kind !== 'fact' && !(record.question.kind === 'freeform' && record.question.scope === 'fact'))); } export class PledgePromiseError extends Error { constructor(message: string) { super(message); this.name = 'PledgePromiseError'; } } const identifier = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value === value.trim(); const positiveInteger = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0; const nonnegativeInteger = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; const optionalBoolean = (value: unknown): boolean => value === undefined || typeof value === 'boolean'; export function parsePledgeClaim(value: unknown): PledgeClaim { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new PledgePromiseError('Choose a current-phase ally-funding question.'); const input = value as Record; if (!positiveInteger(input.turn) || (input.phase !== 3 && input.phase !== 5) || !identifier(input.ally) || !positiveInteger(input.minimum)) throw new PledgePromiseError('Choose the current bidding or movement phase, your ally, and a positive safe whole funding minimum.'); return { turn: input.turn, phase: input.phase, ally: input.ally, minimum: input.minimum }; } /** Validates the saved public answer and participant identities without reading * another player's hand, wallet, pledged amount or private commitments. */ export function validatePledgePromises(promises: readonly PledgePromise[], players: readonly string[]): void { if (!Array.isArray(promises) || !Array.isArray(players)) throw new PledgePromiseError('The ally-funding commitment record is invalid.'); const events = new Set(); for (const promise of promises) { parsePledgeClaim(promise); if (!identifier(promise.event) || events.has(promise.event) || !identifier(promise.player) || !identifier(promise.asker) || !players.includes(promise.player) || !players.includes(promise.asker) || !players.includes(promise.ally) || promise.player === promise.asker || promise.player === promise.ally || typeof promise.answer !== 'boolean' || !optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) || promise.fulfilled && promise.released || !promise.answer && promise.released) throw new PledgePromiseError('The ally-funding commitment lost its event, participants or answer.'); events.add(promise.event); } } export function livePledgePromises(promises: readonly PledgePromise[], player: string, turn: number, phase: number): PledgePromise[] { return promises.filter(promise => promise.player === player && promise.turn === turn && promise.phase === phase && !promise.fulfilled && !promise.released); } /** Outstanding Yes answers can share one future update; each No supplies an * exclusive upper bound. Existing escrow never fulfills a newly asked question. */ export function pledgePromiseRequirements(promises: readonly PledgePromise[], player: string, turn: number, phase: number, ally: string): PledgeRequirement | null { if (!identifier(ally)) throw new PledgePromiseError('Choose a current mutual ally.'); let minimum = 0, maximum: number | undefined; for (const promise of livePledgePromises(promises, player, turn, phase)) { parsePledgeClaim(promise); if (promise.ally !== ally) return null; if (promise.answer) minimum = Math.max(minimum, promise.minimum); else maximum = Math.min(maximum ?? Number.MAX_SAFE_INTEGER, promise.minimum - 1); } if (maximum !== undefined && minimum > maximum) return null; return { ally, minimum, ...(maximum === undefined ? {} : { maximum }) }; } /** Apply only after the original native pledge action has passed its legal * quote. Same-value explicit updates count; unrelated earlier escrow does not. * A rejected No leaves all original records unchanged. */ export function recordPromisedPledge(promises: readonly PledgePromise[], player: string, turn: number, phase: number, update: PledgeUpdate): PledgePromise[] { if (!update || typeof update !== 'object' || Array.isArray(update) || !identifier(update.ally) || !nonnegativeInteger(update.amount)) throw new PledgePromiseError('Retain the actual ally and nonnegative whole funding update.'); const live = livePledgePromises(promises, player, turn, phase); for (const promise of live) { parsePledgeClaim(promise); if (promise.ally === update.ally && !promise.answer && update.amount >= promise.minimum) throw new PledgePromiseError('This funding update conflicts with your Truthtrance No answer.'); } return promises.map(promise => live.includes(promise) && promise.ally === update.ally && promise.answer && update.amount >= promise.minimum ? { ...promise, fulfilled: true } : promise); } export type PledgeReconciliation = { turn: number; phase: number; /** Must return an ally only for a current reciprocal partnership. */ mutualAlly: (player: string) => string | null; /** A conservative native future-completion quote, not a secret opponent * choice or a requirement that an interruptible action be legal right now. */ canFulfill: (promise: PledgePromise, requirement: PledgeRequirement) => boolean; /** The player whose voluntary accepted action led to this state, if any. */ voluntaryActor?: string; }; /** No remains binding through the named phase and reciprocal alliance. An * unfulfilled Yes is released only if an outside action or natural transition * makes completion impossible; its owner cannot voluntarily create that loss. * The engine owns the legal quote and must avoid speculative hidden funds. */ export function reconcilePledgePromises(promises: readonly PledgePromise[], context: PledgeReconciliation): PledgePromise[] { if (!Array.isArray(promises) || !context || !positiveInteger(context.turn) || !Number.isSafeInteger(context.phase) || typeof context.mutualAlly !== 'function' || typeof context.canFulfill !== 'function' || context.voluntaryActor !== undefined && !identifier(context.voluntaryActor)) throw new PledgePromiseError('Retain the current ally-funding reconciliation context.'); return promises.map(promise => { if (promise.fulfilled || promise.released) return promise; parsePledgeClaim(promise); const inScope = promise.turn === context.turn && promise.phase === context.phase && context.mutualAlly(promise.player) === promise.ally; if (!promise.answer) return inScope ? promise : { ...promise, fulfilled: true }; const requirement = inScope ? pledgePromiseRequirements(promises, promise.player, context.turn, context.phase, promise.ally) : null; if (inScope && requirement && context.canFulfill(promise, requirement)) return promise; if (context.voluntaryActor === promise.player) throw new PledgePromiseError('You cannot voluntarily evade your Truthtrance ally-funding answer.'); return { ...promise, released: true }; }); }