Files
Dune/game/discovery-flight.ts

233 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
consumeOrnithopter,
isDiscoveryTokenId,
validateDiscoveryState,
type DiscoveryOpaqueTokenId,
type DiscoveryState,
type ProjectedDiscoveryToken,
} from './discoveries';
import type { MovementMarker } from './ornithopter';
export class DiscoveryFlightError extends Error {
constructor(message: string) {
super(message);
this.name = 'DiscoveryFlightError';
}
}
/** Structural subset of an ordinary canonical MovementOrder, never an action. */
export type DiscoveryFlightMovement = {
source?: string;
player: string;
group: readonly (readonly [string, number])[];
eliteGroup: Readonly<Record<string, number>>;
elite: number;
origin: string;
origins?: readonly string[];
planetologist?: { leader: string; mode: 'range' | 'gather' };
total: number;
to: string;
sector: number;
advisors: boolean;
wantsFighters: boolean;
lockedTurn?: number;
noField?: MovementMarker;
};
/** Kept on the pending order until that one group actually moves. No token is
* consumed by creating this receipt or by an opposing response. */
export type DiscoveryFlightReceipt = {
token: DiscoveryOpaqueTokenId;
player: string;
turn: number;
move: number;
acquiredTurn: number;
selection: string;
};
type Player = { id: string; moved: number };
type Context = {
status: string;
turn: number;
phase: number;
active: string | null;
players: readonly Player[];
};
export type DiscoveryFlightContext = Context & {
discoveries?: DiscoveryState | null;
};
export type DiscoveryFlightOfferContext = Context & {
discoveries?: {
tokens: readonly Pick<ProjectedDiscoveryToken,
'id' | 'face' | 'status' | 'owner' | 'acquiredTurn' | 'revealedTurn'>[];
} | null;
};
const integer = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const name = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireFlight(value: unknown, message: string): asserts value {
if (!value) throw new DiscoveryFlightError(message);
}
function validSource(value: unknown, origin: string): value is string {
if (typeof value !== 'string') return false;
const prefix = `${origin}:`, sector = Number(value.slice(prefix.length));
return value.startsWith(prefix) && integer(sector) && sector <= 18 && value === `${prefix}${sector}`;
}
/** Fixed whitelist: a stored receipt or the separate Treachery flight fields
* cannot recursively enter this signature. Entry and route legality stay with
* the canonical movement quote, including publicly revealed board locations. */
function selection(move: DiscoveryFlightMovement): string {
requireFlight(record(move) && move.source === undefined && name(move.player) && name(move.origin) &&
name(move.to) && integer(move.sector) && move.sector <= 18 &&
typeof move.advisors === 'boolean' && typeof move.wantsFighters === 'boolean' &&
(!move.wantsFighters || move.advisors) &&
(move.lockedTurn === undefined || integer(move.lockedTurn)) &&
Array.isArray(move.group) && record(move.eliteGroup),
'Bind the Discovery Ornithopter to an ordinary declared movement group.');
const skill = move.planetologist;
requireFlight(skill === undefined || (record(skill) && name(skill.leader) &&
(skill.mode === 'range' || skill.mode === 'gather')),
'The Discovery flight has an invalid Planetologist selection.');
const origins = move.origins ?? [move.origin];
requireFlight(skill?.mode === 'gather'
? Array.isArray(move.origins) && origins.length === 2 &&
origins.every(name) && new Set(origins).size === 2 && origins[0] === move.origin
: move.origins === undefined,
'A Discovery gather must retain exactly two normalized Planetologist origins.');
const group = move.group;
requireFlight(group.every(part => Array.isArray(part) && part.length === 2 &&
origins.some(origin => validSource(part[0], origin)) && integer(part[1]) && part[1] > 0) &&
new Set(group.map(([key]) => key)).size === group.length,
'The Discovery flight has invalid or repeated source forces.');
requireFlight(Object.entries(move.eliteGroup).every(([key, count]) => integer(count) &&
group.some(([source, total]) => source === key && count <= total)),
'The Discovery flight has invalid typed forces.');
const total = group.reduce((sum, [, amount]) => sum + amount, 0);
const elite = Object.values(move.eliteGroup).reduce((sum, amount) => sum + amount, 0);
requireFlight(integer(total) && integer(elite) && integer(move.total) && move.total > 0 &&
move.total === total + Number(!!move.noField) && integer(move.elite) && move.elite === elite,
'The Discovery flight must retain its exact physical force count.');
if (move.noField) requireFlight(record(move.noField) &&
name(move.noField.tokenId) && name(move.noField.event) && validSource(move.noField.from, move.origin),
'The Discovery flight has lost its selected No-Field marker.');
requireFlight(origins.every(origin => group.some(([key]) => validSource(key, origin)) ||
(move.noField && validSource(move.noField.from, origin))),
'Every Discovery gather origin must supply actual selected forces or its marker.');
const destination = `${move.to}:${move.sector}`;
requireFlight(group.every(([key]) => key !== destination) && move.noField?.from !== destination,
'The Discovery flight must move its selected group to a different location.');
return JSON.stringify([
move.player, move.origin,
[...group].sort(([a], [b]) => a.localeCompare(b)).map(([key, amount]) =>
[key, amount, move.eliteGroup[key] ?? 0]),
move.elite, move.total, move.to, move.sector, move.advisors, move.wantsFighters,
move.lockedTurn ?? null,
move.noField ? [move.noField.tokenId, move.noField.event, move.noField.from] : null,
// Preserve existing no-skill single-origin receipt signatures byte for byte.
...(skill ? [[skill.leader, skill.mode, [...origins]]] : []),
]);
}
function currentPlayer(context: Context, player: string): Player {
const owner = context.players.find(candidate => candidate.id === player);
requireFlight(context.status === 'playing' && context.phase === 5 && context.active === player &&
integer(context.turn) && context.turn > 0 && owner && integer(owner.moved),
'Use the Discovery Ornithopter during its owner’s current movement turn.');
return owner;
}
function inventory(context: DiscoveryFlightContext): DiscoveryState {
requireFlight(context.discoveries, 'No physical Discovery token inventory is available.');
try { validateDiscoveryState(context.discoveries); }
catch (error) {
throw new DiscoveryFlightError(error instanceof Error ? error.message : 'Invalid Discovery token custody.');
}
return context.discoveries;
}
function carriedToken(context: DiscoveryFlightContext, token: unknown, player: string) {
const state = inventory(context);
requireFlight(isDiscoveryTokenId(token), 'Choose the physical Discovery Ornithopter token.');
const held = state.tokens.find(candidate => candidate.id === token);
requireFlight(held?.face === 'ornithopter' && held.status === 'carried' && held.owner === player,
'That player does not carry this Discovery Ornithopter.');
requireFlight(integer(held.acquiredTurn) && held.acquiredTurn > 0 && held.acquiredTurn < context.turn,
'The Discovery Ornithopter may be used only on a turn after it was acquired.');
return held;
}
/** Projected eligibility only. The engine still supplies ordinary movement
* allowances and interaction locks, and quotes the selected route separately. */
export function discoveryFlightOffer(
context: DiscoveryFlightOfferContext,
owner: string,
options: { movesAllowed?: number; blocked?: string | null } = {},
): { token: DiscoveryOpaqueTokenId; acquiredTurn: number; range: 3; blocked: string | null } | null {
const player = context.players.find(player => player.id === owner);
const tokens = context.discoveries?.tokens.filter(token =>
token.face === 'ornithopter' && token.status === 'carried' && token.owner === owner) ?? [];
if (!player || tokens.length !== 1 || !isDiscoveryTokenId(tokens[0].id) ||
!integer(tokens[0].acquiredTurn) || tokens[0].acquiredTurn < 1 ||
tokens[0].revealedTurn !== tokens[0].acquiredTurn) return null;
const token = tokens[0];
const blocked = !integer(context.turn) || context.turn < 1 || !integer(player.moved)
? 'The current movement turn is unavailable.'
: token.acquiredTurn! >= context.turn
? 'Use this token on a turn after it was acquired.'
: context.status !== 'playing' || context.phase !== 5 || context.active !== owner
? 'Use this token during your own Shipment and Movement turn.'
: options.blocked ?? (options.movesAllowed !== undefined &&
(!integer(options.movesAllowed) || player.moved >= options.movesAllowed)
? 'No ordinary movement action remains.' : null);
return { token: token.id, acquiredTurn: token.acquiredTurn!, range: 3, blocked };
}
/** Creates an unspent selection. Run the normal movement/arrival quote before
* publishing it; this never grants another movement action or spends the token. */
export function quoteDiscoveryFlight(
context: DiscoveryFlightContext,
token: unknown,
move: DiscoveryFlightMovement,
): DiscoveryFlightReceipt {
const player = currentPlayer(context, move.player);
const held = carriedToken(context, token, player.id);
return {
token: held.id, player: player.id, turn: context.turn, move: player.moved,
acquiredTurn: held.acquiredTurn!, selection: selection(move),
};
}
function validateReceipt(context: DiscoveryFlightContext, receipt: DiscoveryFlightReceipt,
move: DiscoveryFlightMovement, completed: boolean) {
requireFlight(record(receipt) && Object.keys(receipt).sort().join(',') ===
'acquiredTurn,move,player,selection,token,turn' && isDiscoveryTokenId(receipt.token) &&
name(receipt.player) && integer(receipt.turn) && receipt.turn > 0 &&
integer(receipt.move) && integer(receipt.acquiredTurn) && receipt.acquiredTurn > 0 &&
typeof receipt.selection === 'string', 'The saved Discovery flight receipt is invalid.');
const player = currentPlayer(context, receipt.player);
requireFlight(receipt.player === move.player && receipt.turn === context.turn &&
player.moved === receipt.move + Number(completed) && receipt.selection === selection(move),
'The Discovery flight no longer matches its original turn, movement or selected group.');
const token = carriedToken(context, receipt.token, receipt.player);
requireFlight(token.acquiredTurn === receipt.acquiredTurn,
'The Discovery flight no longer matches its token’s acquisition.');
}
/** Recheck the same owned group after a pending response or saved continuation.
* Response controls themselves are not a reason to invalidate the receipt. */
export function validateDiscoveryFlight(context: DiscoveryFlightContext,
receipt: DiscoveryFlightReceipt, move: DiscoveryFlightMovement): void {
validateReceipt(context, receipt, move, false);
}
/** Call only after the canonical group physically moves and its movement count
* advances once, before post-arrival effects. Return a new inventory for the
* engine's atomic working copy; rejected or abandoned movement never calls this. */
export function completeDiscoveryFlight(context: DiscoveryFlightContext,
receipt: DiscoveryFlightReceipt, move: DiscoveryFlightMovement): DiscoveryState {
validateReceipt(context, receipt, move, true);
return consumeOrnithopter(context.discoveries!, receipt.player);
}