Files
Dune/game/ordered-opportunity.ts
T
2026-09-08 07:37:52 +10:00

162 lines
5.4 KiB
TypeScript

/** One finite, publicly ordered scope. Remaining includes the current actor first. */
export type OrderedOpportunity = {
readonly event: string;
readonly eligible: readonly string[];
readonly remaining: readonly string[];
readonly completed: readonly string[];
readonly current: string | null;
readonly currentStarted: boolean;
readonly protectedLast?: string | null;
};
export type OrderedOpportunityRequest = {
event: string;
player: string;
position: 'first' | 'last';
};
export class OrderedOpportunityError extends Error {
constructor(message: string) {
super(message);
this.name = 'OrderedOpportunityError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new OrderedOpportunityError(message);
}
function ids(value: unknown): value is readonly string[] {
return (
Array.isArray(value) &&
value.every((id) => typeof id === 'string' && id.trim().length > 0) &&
new Set(value).size === value.length
);
}
/** Reject corrupt snapshots; never repair them by dropping or restoring a player. */
export function validateOrderedOpportunity(state: OrderedOpportunity): void {
check(
state && typeof state === 'object',
'Choose a valid ordered opportunity.',
);
check(
typeof state.event === 'string' && state.event.trim().length > 0,
'The ordered opportunity requires a stable event.',
);
check(
ids(state.eligible) && ids(state.remaining) && ids(state.completed),
'Opportunity participants must be unique, nonempty player IDs.',
);
const pending = new Set(state.remaining),
completed = new Set(state.completed);
check(
state.remaining.length + state.completed.length === state.eligible.length &&
state.eligible.every((id) => pending.has(id) !== completed.has(id)),
'Remaining and completed players must partition the eligible players exactly.',
);
check(
state.current === (state.remaining[0] ?? null),
'The current actor must be first in the remaining opportunities.',
);
check(
typeof state.currentStarted === 'boolean' &&
(!state.currentStarted || state.current !== null),
'Only an existing current opportunity can have started.',
);
check(
state.protectedLast === undefined ||
state.protectedLast === null ||
(typeof state.protectedLast === 'string' &&
pending.has(state.protectedLast) &&
state.remaining[state.remaining.length - 1] === state.protectedLast),
'The protected last player must remain last in this scope.',
);
}
function snapshot(
state: OrderedOpportunity,
remaining = state.remaining,
): OrderedOpportunity {
return {
...state,
eligible: [...state.eligible],
completed: [...state.completed],
remaining: [...remaining],
current: remaining[0] ?? null,
};
}
function participant(
state: OrderedOpportunity,
request: { event: string; player: string },
) {
validateOrderedOpportunity(state);
check(
request && request.event === state.event,
'This ordered opportunity has expired.',
);
check(
state.eligible.includes(request.player),
'The player is not eligible for this opportunity.',
);
check(
!state.completed.includes(request.player),
'The player already completed this opportunity.',
);
}
/**
* Reorder only unstarted units. First means the front of the unstarted remainder,
* after a locked current unit; last respects existing last-position protection.
* A card adapter must separately decide whether that boundary fits its rules.
* This helper grants no additional turn and defines no cyclic/phase duration.
*/
export function reorderOrderedOpportunity(
state: OrderedOpportunity,
request: OrderedOpportunityRequest,
): OrderedOpportunity {
participant(state, request);
check(
request.position === 'first' || request.position === 'last',
'Choose first or last.',
);
const { player, position } = request;
if (state.currentStarted && player === state.current) {
check(
position === 'first' || state.remaining.length === 1,
'Moving this actor would split a started opportunity; that scope is unsupported.',
);
return snapshot(state);
}
const locked = state.currentStarted ? [state.current!] : [];
const available = state.remaining.slice(locked.length);
if (player === state.protectedLast) {
check(
position === 'last' || available.length === 1,
'The protected last player cannot move ahead of other remaining players.',
);
return snapshot(state);
}
const rest = available.filter((id) => id !== player);
const at =
position === 'first'
? 0
: state.protectedLast
? rest.indexOf(state.protectedLast)
: rest.length;
rest.splice(at, 0, player);
const result = snapshot(state, [...locked, ...rest]);
validateOrderedOpportunity(result);
return result;
}
/** Establish explicit scoped last protection; another priority rule must resolve conflicts. */
export function protectOrderedOpportunityLast(
state: OrderedOpportunity,
request: { event: string; player: string },
): OrderedOpportunity {
participant(state, request);
check(
!state.protectedLast || state.protectedLast === request.player,
'Competing last-position protection requires an explicit scope rule.',
);
const result = {
...reorderOrderedOpportunity(state, { ...request, position: 'last' }),
protectedLast: request.player,
};
validateOrderedOpportunity(result);
return result;
}