Support Sapho ordering for simultaneous Truthtrances

This commit is contained in:
Quintine committed 2026-10-27 18:31:15 +11:00
1 parent 4690200f59
commit 298c8245a6
16 files changed
+1020 -3

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
/** Public pre-question opportunity for groups of simultaneous Truth cards.
* The engine owns the physical Sapho and each queued Truth card. */
export type SaphoTruthOrderFrame = {
event: string;
revision: number;
holders: string[];
passed: string[];
/** Earlier Sapho choices remain in their declared boundary positions. */
protectedFirst?: string;
protectedLast?: string;
};
export type SaphoTruthOrderEntry = { player: string };
export type SaphoTruthOrderMode = 'first' | 'last';
export type SaphoTruthOrderRequest = { event: string; revision: number; player: string };
export type SaphoTruthOrderResult<T extends SaphoTruthOrderEntry> = {
frame: SaphoTruthOrderFrame;
queue: T[];
done: boolean;
};
export class SaphoTruthOrderError extends Error {
constructor(message: string) {
super(message);
this.name = 'SaphoTruthOrderError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new SaphoTruthOrderError(message);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.trim() === value;
const uniqueIdentifiers = (value: unknown): value is string[] =>
Array.isArray(value) && value.every(identifier) && new Set(value).size === value.length;
/** The queue is already sorted by the real Truth priority rule. Each holder's
* physical cards must remain one contiguous group throughout this window. */
function holders<T extends SaphoTruthOrderEntry>(queue: readonly T[]): string[] {
check(Array.isArray(queue) && queue.every(row => row && identifier(row.player)),
'Retain the original queued Truth holders.');
const result: string[] = [];
for (const row of queue) {
if (result.at(-1) === row.player) continue;
check(!result.includes(row.player), 'A holder’s simultaneous Truth cards must remain together.');
result.push(row.player);
}
return result;
}
/** Creates a frame solely from public committed holders, not any hand or card
* contents. No added response stage is needed for fewer than two holders. */
export function createSaphoTruthOrder<T extends SaphoTruthOrderEntry>(
queue: readonly T[], event: string,
): SaphoTruthOrderFrame | null {
check(identifier(event), 'The Truth ordering window needs an opaque event.');
const originalHolders = holders(queue);
return originalHolders.length < 2 ? null : { event, revision: 0, holders: originalHolders, passed: [] };
}
export function validateSaphoTruthOrder<T extends SaphoTruthOrderEntry>(
frame: SaphoTruthOrderFrame, queue: readonly T[],
): void {
check(frame && typeof frame === 'object' && identifier(frame.event) &&
Number.isSafeInteger(frame.revision) && frame.revision >= 0,
'Retain the current Truth ordering event and revision.');
check(uniqueIdentifiers(frame.holders) && frame.holders.length >= 2 && uniqueIdentifiers(frame.passed) &&
frame.passed.every(player => frame.holders.includes(player)),
'Retain the original holders and their completed ordering responses.');
const current = holders(queue);
check(current.length === frame.holders.length && current.every(player => frame.holders.includes(player)),
'Retain every original Truth holder in this ordering window.');
check(frame.revision === frame.passed.length,
'Retain one ordering revision for each completed holder response.');
check(frame.protectedFirst === undefined || identifier(frame.protectedFirst) &&
frame.passed.includes(frame.protectedFirst) && current[0] === frame.protectedFirst,
'Retain the holder protected at the first Truth position.');
check(frame.protectedLast === undefined || identifier(frame.protectedLast) &&
frame.passed.includes(frame.protectedLast) && current.at(-1) === frame.protectedLast,
'Retain the holder protected at the last Truth position.');
}
function participant<T extends SaphoTruthOrderEntry>(
frame: SaphoTruthOrderFrame, queue: readonly T[], request: SaphoTruthOrderRequest,
): string[] {
validateSaphoTruthOrder(frame, queue);
check(request && request.event === frame.event && request.revision === frame.revision,
'This Truth ordering opportunity has expired.');
check(identifier(request.player) && frame.holders.includes(request.player),
'Only an original Truth holder may respond to this ordering window.');
check(!frame.passed.includes(request.player), 'This holder already completed the ordering opportunity.');
return holders(queue);
}
export function quoteSaphoTruthOrder<T extends SaphoTruthOrderEntry>(
frame: SaphoTruthOrderFrame, queue: readonly T[], player: string,
): { event: string; revision: number; modes: SaphoTruthOrderMode[] } {
validateSaphoTruthOrder(frame, queue);
const {event,revision} = frame, current = participant(frame, queue, { event, revision, player });
const modes: SaphoTruthOrderMode[] = [];
if (current[0] !== player && !frame.protectedFirst && frame.protectedLast !== player) modes.push('first');
if (current.at(-1) !== player && !frame.protectedLast && frame.protectedFirst !== player) modes.push('last');
return { event, revision, modes };
}
function complete<T extends SaphoTruthOrderEntry>(
frame: SaphoTruthOrderFrame, queue: readonly T[], player: string,
): SaphoTruthOrderResult<T> {
check(frame.revision < Number.MAX_SAFE_INTEGER, 'The Truth ordering revision cannot advance safely.');
const next = { ...frame, revision: frame.revision + 1, holders: [...frame.holders], passed: [...frame.passed, player] };
return { frame: next, queue: [...queue], done: next.passed.length === next.holders.length };
}
/** Passing is available to every original holder, independently of cards held. */
export function passSaphoTruthOrder<T extends SaphoTruthOrderEntry>(
frame: SaphoTruthOrderFrame, queue: readonly T[], request: SaphoTruthOrderRequest,
): SaphoTruthOrderResult<T> {
participant(frame, queue, request);
return complete(frame, queue, request.player);
}
/** Move the entire holder group; never sort or alter the physical card rows. */
export function reorderSaphoTruthOrder<T extends SaphoTruthOrderEntry>(
frame: SaphoTruthOrderFrame, queue: readonly T[], request: SaphoTruthOrderRequest & { mode: SaphoTruthOrderMode },
): SaphoTruthOrderResult<T> {
const current = participant(frame, queue, request);
check(request.mode === 'first' || request.mode === 'last', 'Choose first or last.');
check(request.mode === 'first' ? current[0] !== request.player : current.at(-1) !== request.player,
'The holder already occupies that Truth position.');
check(request.mode === 'first' ? !frame.protectedFirst && frame.protectedLast !== request.player
: !frame.protectedLast && frame.protectedFirst !== request.player,
'An earlier Juice of Sapho use protects that Truth position.');
const own = queue.filter(row => row.player === request.player), rest = queue.filter(row => row.player !== request.player);
const result = complete(frame, request.mode === 'first' ? [...own, ...rest] : [...rest, ...own], request.player);
if (request.mode === 'first') result.frame.protectedFirst = request.player;
else result.frame.protectedLast = request.player;
return result;
}