import { protectOrderedOpportunityLast, reorderOrderedOpportunity, validateOrderedOpportunity, type OrderedOpportunity, } from './ordered-opportunity'; export type SaphoMovementOrderContext = { queue: OrderedOpportunity; started: readonly string[]; /** Present only for Advanced Guild timing. */ guild: { player: string; granted: boolean; locked: boolean } | null; /** Explicit clean-boundary prototype; the engine owns all action state. */ allowStartedLast?: boolean; }; export class SaphoMovementOrderError extends Error { constructor(message: string) { super(message); this.name = 'SaphoMovementOrderError'; } } function requireMovement(value: unknown, message: string): asserts value { if (!value) throw new SaphoMovementOrderError(message); } const identifier = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; function validate(context: SaphoMovementOrderContext): OrderedOpportunity { requireMovement(context && typeof context === 'object', 'Choose the current movement order.'); const queue = context.queue; validateOrderedOpportunity(queue); requireMovement(queue.current !== null && queue.remaining.length > 0, 'The combined movement turns have already finished.'); requireMovement(Array.isArray(context.started) && context.started.every(identifier) && new Set(context.started).size === context.started.length && context.started.every(player => queue.eligible.includes(player)), 'Started movement turns must name unique participants in this phase.'); const started = queue.remaining.filter(player => context.started.includes(player)); // A previous self-Last can suspend one started actor at the protected tail. // Its already-spent movement and shipment remain in the engine's counters. // No other noncurrent started participant is admitted. requireMovement(started.includes(queue.current) === queue.currentStarted && started.every(player => player === queue.current || player === queue.protectedLast), 'Only the current or protected suspended combined turn may have started among the remaining participants.'); requireMovement(context.guild === null || context.guild && typeof context.guild === 'object' && identifier(context.guild.player) && queue.eligible.includes(context.guild.player) && typeof context.guild.granted === 'boolean' && typeof context.guild.locked === 'boolean', 'Guild timing must retain its original participant and public grant state.'); requireMovement(context.allowStartedLast === undefined || typeof context.allowStartedLast === 'boolean', 'The started-turn Sapho scope must be an explicit boolean.'); return queue; } function eventFor(queue: OrderedOpportunity): string { return queue.currentStarted ? `sapho-after:${encodeURIComponent(JSON.stringify([queue.event, queue.current, queue.remaining]))}` : queue.event; } function wrap(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof SaphoMovementOrderError) throw error; throw new SaphoMovementOrderError(error instanceof Error ? error.message : 'Invalid Sapho movement order.'); } } /** A started turn binds the whole public tail, rather than permitting a stale * declaration to displace a different actor or newly reordered participants. */ export function saphoMovementOrderEvent(context: SaphoMovementOrderContext): string { return wrap(() => eventFor(validate(context))); } export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, request: { event: string; player: string; mode: 'first' | 'last'; }): { event: string; afterCurrent: string | null; suspended?: string; next: OrderedOpportunity } { return wrap(() => { const original = validate(context), event = eventFor(original); requireMovement(request && request.event === event, 'This Sapho movement opportunity has expired.'); requireMovement(request.mode === 'first' || request.mode === 'last', 'Choose first or last.'); requireMovement(identifier(request.player) && original.remaining.includes(request.player), 'Only a pending participant may change its movement position.'); // A recovered physical Sapho may supersede its own earlier Last use. // Another seat cannot replace that protection under this bounded policy. if (original.protectedLast) { requireMovement(original.protectedLast === request.player && request.mode === 'first', 'Only the protected player may relinquish their earlier last position.'); } const suspendedReplay = original.protectedLast === request.player && original.current !== request.player && context.started.includes(request.player); const queue = original.protectedLast ? { ...original, protectedLast: null } : original; if (context.started.includes(request.player) && request.player === queue.current && context.allowStartedLast === true) { requireMovement(request.mode === 'last', 'An already-started turn can only be moved to last.'); requireMovement(queue.remaining.length > 1, 'This participant is already last.'); // Suspend this same opportunity; never synthesize a completed turn or a // second opportunity. The engine retains its committed action counters. const remaining = [...queue.remaining.slice(1), request.player]; const next: OrderedOpportunity = { ...queue, eligible: [...queue.eligible], completed: [...queue.completed], remaining, current: remaining[0], currentStarted: false, protectedLast: request.player, }; validateOrderedOpportunity(next); return { event, afterCurrent: null, suspended: request.player, next }; } requireMovement(!context.started.includes(request.player) || suspendedReplay, 'Juice of Sapho cannot split a combined turn that has already started.'); const grantedGuild = context.guild && context.guild.player === queue.current && context.guild.granted && !context.guild.locked; requireMovement(queue.currentStarted || request.mode !== 'first' || !grantedGuild, 'The Guild has already been granted the next combined turn.'); const reordered = request.mode === 'last' ? protectOrderedOpportunityLast(queue, { event: queue.event, player: request.player }) : reorderOrderedOpportunity(queue, { event: queue.event, player: request.player, position: 'first' }); const next = suspendedReplay && reordered.current === request.player ? { ...reordered, currentStarted: true } : reordered; validateOrderedOpportunity(next); requireMovement(next.remaining.some((player, index) => player !== queue.remaining[index]), 'This participant is already in that remaining movement position.'); return { event, afterCurrent: queue.currentStarted ? queue.current : null, next }; }); }