117 lines
5.8 KiB
TypeScript
117 lines
5.8 KiB
TypeScript
/** A definite answer about claiming charity after the answer in the current
|
|
* Charity phase. Native eligibility and the cause of an opportunity changing
|
|
* remain authoritative in the engine. */
|
|
export type CharityPromise = {
|
|
event: string;
|
|
turn: number;
|
|
player: string;
|
|
asker: string;
|
|
answer: boolean;
|
|
fulfilled: boolean;
|
|
released: boolean;
|
|
};
|
|
|
|
export class CharityPromiseError extends Error {}
|
|
export type CharityPromiseAnswer = 'yes' | 'no';
|
|
export type CharityPromiseChange = 'voluntary' | 'external';
|
|
|
|
function checkContext(player: string, turn: number): void {
|
|
if (typeof player !== 'string' || !player.trim() || player !== player.trim() ||
|
|
!Number.isSafeInteger(turn) || turn < 1)
|
|
throw new CharityPromiseError('Retain the original player and turn for the Charity answer.');
|
|
}
|
|
|
|
/** Each physical answer owns a distinct stable receipt across continuations. */
|
|
export function validateCharityPromises(promises: readonly CharityPromise[]): void {
|
|
if (!Array.isArray(promises)) throw new CharityPromiseError('The Charity answer receipts are invalid.');
|
|
const events = new Set<string>();
|
|
for (const row of promises) {
|
|
if (!row || typeof row.event !== 'string' || !row.event.trim() || row.event !== row.event.trim() ||
|
|
events.has(row.event) || typeof row.player !== 'string' || !row.player.trim() ||
|
|
row.player !== row.player.trim() || typeof row.asker !== 'string' || !row.asker.trim() ||
|
|
row.asker !== row.asker.trim() || row.asker === row.player ||
|
|
!Number.isSafeInteger(row.turn) || row.turn < 1 || typeof row.answer !== 'boolean' ||
|
|
typeof row.fulfilled !== 'boolean' || typeof row.released !== 'boolean' || row.fulfilled && row.released)
|
|
throw new CharityPromiseError('A Charity answer lost its unique event, participants or outcome.');
|
|
events.add(row.event);
|
|
}
|
|
}
|
|
|
|
/** A No remains live through phase close, even if concurrent Basic Ready is
|
|
* later reopened. A Yes completes only when the actual claim is accepted. */
|
|
export function liveCharityPromises<T extends CharityPromise>(
|
|
promises: readonly T[], player: string, turn: number,
|
|
): T[] {
|
|
checkContext(player, turn);
|
|
validateCharityPromises(promises);
|
|
return promises.filter(row => row.player === player && row.turn === turn && !row.fulfilled && !row.released);
|
|
}
|
|
|
|
/** canClaim is the native, current opportunity, including funding and prior
|
|
* claim rules. It must not be a temporary inability while a response is open. */
|
|
export function charityPromiseAnswers(
|
|
promises: readonly CharityPromise[], player: string, turn: number, canClaim: boolean,
|
|
): CharityPromiseAnswer[] {
|
|
const live = liveCharityPromises(promises, player, turn);
|
|
return [
|
|
...(canClaim && !live.some(row => !row.answer) ? ['yes' as const] : []),
|
|
...(!live.some(row => row.answer) ? ['no' as const] : []),
|
|
];
|
|
}
|
|
|
|
/** Called only for a definite accepted answer, before the Truth card's actual
|
|
* discard. The engine reopens Basic readiness separately. */
|
|
export function bindCharityPromise(
|
|
promises: readonly CharityPromise[], player: string, asker: string, turn: number,
|
|
answer: boolean, canClaim: boolean, event: string = crypto.randomUUID(),
|
|
): CharityPromise[] {
|
|
checkContext(asker, turn);
|
|
if (asker === player || typeof answer !== 'boolean' ||
|
|
!charityPromiseAnswers(promises, player, turn, canClaim).includes(answer ? 'yes' : 'no'))
|
|
throw new CharityPromiseError('Choose a Charity answer consistent with the current opportunity and previous answers.');
|
|
const next = [...promises, { event, turn, player, asker, answer, fulfilled: false, released: false }];
|
|
validateCharityPromises(next);
|
|
return next;
|
|
}
|
|
|
|
/** Invoke after native claim validation, before the actual claim settles. */
|
|
export function guardCharityClaim<T extends CharityPromise>(
|
|
promises: readonly T[], player: string, turn: number,
|
|
): T[] {
|
|
const live = liveCharityPromises(promises, player, turn);
|
|
if (live.some(row => !row.answer))
|
|
throw new CharityPromiseError('Your Truthtrance No prevents claiming Charity this phase.');
|
|
return promises.map(row => live.includes(row) && row.answer ? { ...row, fulfilled: true } : row);
|
|
}
|
|
|
|
/** A reachable affirmative claim must happen before this player confirms Ready. */
|
|
export function guardCharityReady(
|
|
promises: readonly CharityPromise[], player: string, turn: number, canClaim: boolean,
|
|
): void {
|
|
if (canClaim && liveCharityPromises(promises, player, turn).some(row => row.answer))
|
|
throw new CharityPromiseError('Claim Charity before finishing your Truthtrance Yes.');
|
|
}
|
|
|
|
/** Reconcile only at a stable native boundary. A voluntary change may not
|
|
* sacrifice an attainable Yes; an externally impossible Yes is released. No
|
|
* resolves only when the original phase closes, never merely on Ready. */
|
|
export function reconcileCharityPromises<T extends CharityPromise>(
|
|
promises: readonly T[], player: string, turn: number,
|
|
opportunity: { phaseOpen: boolean; canClaim: boolean; change: CharityPromiseChange },
|
|
): T[] {
|
|
const live = liveCharityPromises(promises, player, turn);
|
|
if (!opportunity || typeof opportunity.phaseOpen !== 'boolean' ||
|
|
typeof opportunity.canClaim !== 'boolean' ||
|
|
(opportunity.change !== 'voluntary' && opportunity.change !== 'external'))
|
|
throw new CharityPromiseError('Retain the original Charity opportunity and the cause of its change.');
|
|
if ((!opportunity.phaseOpen || !opportunity.canClaim) && live.some(row => row.answer) &&
|
|
opportunity.change === 'voluntary')
|
|
throw new CharityPromiseError('This action would abandon your Truthtrance Charity Yes.');
|
|
return promises.map(row => {
|
|
if (!live.includes(row)) return row;
|
|
if (row.answer && (!opportunity.phaseOpen || !opportunity.canClaim)) return { ...row, released: true };
|
|
if (!row.answer && !opportunity.phaseOpen) return { ...row, fulfilled: true };
|
|
return row;
|
|
});
|
|
}
|