Files
Dune/game/sapho-movement-order.ts

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 };
});
}