147 lines
8.0 KiB
TypeScript
147 lines
8.0 KiB
TypeScript
/** 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<string, unknown>;
|
|
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<string>();
|
|
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 };
|
|
});
|
|
}
|