/** 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 = { 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(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( 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( 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( 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( 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( frame: SaphoTruthOrderFrame, queue: readonly T[], player: string, ): SaphoTruthOrderResult { 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( frame: SaphoTruthOrderFrame, queue: readonly T[], request: SaphoTruthOrderRequest, ): SaphoTruthOrderResult { 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( frame: SaphoTruthOrderFrame, queue: readonly T[], request: SaphoTruthOrderRequest & { mode: SaphoTruthOrderMode }, ): SaphoTruthOrderResult { 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; }