120 lines
6.7 KiB
TypeScript
120 lines
6.7 KiB
TypeScript
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<T>(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 };
|
|
});
|
|
}
|