Files
Dune/game/movement-expression-promises.ts

237 lines
13 KiB
TypeScript

import { TERRITORIES, validLocation } from './board';
import { MovementClaimError, movementClaimText, parseMovementClaim, type MovementEvent } from './movement-promises';
export { movementPromiseModeSupported as movementExpressionPromiseModeSupported } from './movement-promises';
export type MovementExpression = { kind: 'move'; territory: string; minimum: number; sector?: number } |
{ kind: 'and' | 'or'; terms: MovementExpression[] };
export type MovementExpressionClaim = { turn: number; claim: MovementExpression };
export type MovementExpressionProgressEntry = { territory: string; maximum: number };
export type MovementExpressionProgress = MovementExpressionProgressEntry[];
export type MovementExpressionSectorProgressEntry = { territory: string; sector: number; maximum: number };
export type MovementExpressionSectorProgress = MovementExpressionSectorProgressEntry[];
export type MovementExpressionEvent = MovementEvent;
export type MovementExpressionPromise = MovementExpressionClaim & {
event: string; player: string; asker: string; answer: boolean;
/** Largest actual physical ground move to each named destination after this answer. */
progress: MovementExpressionProgress;
/** Present only for named exact-sector clauses; old territory-only saves omit it. */
sectorProgress?: MovementExpressionSectorProgress;
fulfilled?: boolean; released?: boolean;
};
export const MOVEMENT_EXPRESSION_MAX_DEPTH = 4;
export const MOVEMENT_EXPRESSION_MAX_LEAVES = 16;
export class MovementExpressionPromiseError extends Error {
constructor(message: string) {
super(message);
this.name = 'MovementExpressionPromiseError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new MovementExpressionPromiseError(message);
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
const keys = (value: Record<string, unknown>, expected: string) => Object.keys(value).sort().join(',') === expected;
const count = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 20;
const printedTerritory = (value: unknown): value is string =>
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
/** Validate every subtree before evaluation can short-circuit. The scalar
* parser remains the authority for printed destinations and physical minima. */
export function parseMovementExpression(value: unknown): MovementExpression {
let leaves = 0;
const parse = (input: unknown, depth: number): MovementExpression => {
check(object(input) && depth <= MOVEMENT_EXPRESSION_MAX_DEPTH,
'Use a ground-movement expression within four nested groups.');
if (input.kind === 'and' || input.kind === 'or') {
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
input.terms.length <= MOVEMENT_EXPRESSION_MAX_LEAVES,
'Join two or more ground-movement statements with AND or OR.');
return { kind: input.kind, terms: input.terms.map(term => parse(term, depth + 1)) };
}
check(++leaves <= MOVEMENT_EXPRESSION_MAX_LEAVES,
'Use at most sixteen ground-movement statements.');
check(input.kind === 'move' && (keys(input, 'kind,minimum,territory') ||
keys(input, 'kind,minimum,sector,territory')),
'Choose one physical ground-movement minimum and printed destination.');
try { return { kind: 'move', ...parseMovementClaim(input) }; }
catch (error) {
if (error instanceof MovementClaimError) throw new MovementExpressionPromiseError(error.message);
throw error;
}
};
return parse(value, 0);
}
export function parseMovementExpressionClaim(value: unknown): MovementExpressionClaim {
check(object(value) && keys(value, 'claim,turn') && typeof value.turn === 'number' &&
Number.isSafeInteger(value.turn) && value.turn >= 1,
'Choose the current turn and a ground-movement expression.');
return { turn: value.turn, claim: parseMovementExpression(value.claim) };
}
export function movementExpressionText(expression: MovementExpression): string {
const text = (node: MovementExpression): string => 'terms' in node ?
`(${node.terms.map(text).join(node.kind === 'and' ? ' AND ' : ' OR ')})` : movementClaimText(node);
return text(parseMovementExpression(expression));
}
export function movementExpressionTerritories(expression: MovementExpression): string[] {
const destinations = new Set<string>();
const visit = (node: MovementExpression): void => {
if ('terms' in node) node.terms.forEach(visit);
else destinations.add(node.territory);
};
visit(parseMovementExpression(expression));
return [...destinations];
}
/** Exact named locations, useful for bounded native completion and progress. */
export function movementExpressionSectors(expression: MovementExpression): { territory: string; sector: number }[] {
const locations = new Map<string, { territory: string; sector: number }>();
const visit = (node: MovementExpression): void => {
if ('terms' in node) node.terms.forEach(visit);
else if (node.sector !== undefined) locations.set(JSON.stringify([node.territory, node.sector]),
{ territory: node.territory, sector: node.sector });
};
visit(parseMovementExpression(expression));
return [...locations.values()];
}
function validateProgress(value: unknown): asserts value is MovementExpressionProgress {
check(Array.isArray(value), 'Retain the largest future physical ground move by named destination.');
const destinations = new Set<string>();
for (const entry of value) {
check(object(entry) && keys(entry, 'maximum,territory') && printedTerritory(entry.territory) &&
count(entry.maximum) && !destinations.has(entry.territory),
'Retain unique printed destinations and physical ground-movement maxima from zero to twenty.');
destinations.add(entry.territory);
}
}
function validateSectorProgress(value: unknown): asserts value is MovementExpressionSectorProgress {
check(Array.isArray(value), 'Retain exact-sector physical ground-movement maxima.');
const locations = new Set<string>();
for (const entry of value) {
check(object(entry) && keys(entry, 'maximum,sector,territory') && printedTerritory(entry.territory) &&
typeof entry.sector === 'number' && Number.isSafeInteger(entry.sector) &&
TERRITORIES.find(destination => destination.id === entry.territory)!.sectors.includes(entry.sector) &&
count(entry.maximum) && !locations.has(JSON.stringify([entry.territory, entry.sector])),
'Retain unique printed sectors and physical ground-movement maxima from zero to twenty.');
locations.add(JSON.stringify([entry.territory, entry.sector]));
}
}
function evaluate(expression: MovementExpression, progress: readonly MovementExpressionProgressEntry[],
sectorProgress: readonly MovementExpressionSectorProgressEntry[]): boolean {
if ('terms' in expression) return expression.kind === 'and' ?
expression.terms.every(term => evaluate(term, progress, sectorProgress)) :
expression.terms.some(term => evaluate(term, progress, sectorProgress));
const maximum = expression.sector === undefined
? progress.find(entry => entry.territory === expression.territory)?.maximum
: sectorProgress.find(entry => entry.territory === expression.territory && entry.sector === expression.sector)?.maximum;
return (maximum ?? 0) >= expression.minimum;
}
/** Missing sector progress is never inferred from territory-wide maxima. */
export function evaluateMovementExpression(expression: MovementExpression,
progress: readonly MovementExpressionProgressEntry[],
sectorProgress: readonly MovementExpressionSectorProgressEntry[] = []): boolean {
const parsed = parseMovementExpression(expression);
validateProgress(progress);
validateSectorProgress(sectorProgress);
return evaluate(parsed, progress, sectorProgress);
}
export function liveMovementExpressionPromises<T extends MovementExpressionPromise>(promises: readonly T[], player: string,
turn: number): T[] {
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
function readPromise(promise: MovementExpressionPromise): MovementExpression {
check(object(promise), 'The ground-movement expression commitment record is invalid.');
const { claim } = parseMovementExpressionClaim({ turn: promise.turn, claim: promise.claim });
validateProgress(promise.progress);
const destinations = movementExpressionTerritories(claim), sectors = movementExpressionSectors(claim);
check(!sectors.length || Array.isArray(promise.sectorProgress),
'Retain the separate actual-sector progress for every exact-sector question.');
if (promise.sectorProgress !== undefined) {
validateSectorProgress(promise.sectorProgress);
check(sectors.length > 0 && promise.sectorProgress.every(entry =>
sectors.some(location => location.territory === entry.territory && location.sector === entry.sector) &&
entry.maximum <= (promise.progress.find(progress => progress.territory === entry.territory)?.maximum ?? 0)),
'Retain only named actual-sector maxima bounded by the territory-wide maxima.');
}
check(typeof promise.answer === 'boolean' && optionalBoolean(promise.fulfilled) && optionalBoolean(promise.released) &&
!(promise.fulfilled && promise.released) && promise.progress.every(entry => destinations.includes(entry.territory)),
'Retain a definite whole movement answer and its own matching future physical move progress.');
return claim;
}
/** Call only after a committed ordinary physical ground move. Shipments,
* ambassador moves and marker pieces are not qualifying forces. A marker-only
* event may have amount zero, which cannot advance a positive minimum. */
export function recordMovementExpressionEvent<T extends MovementExpressionPromise>(promises: readonly T[], player: string,
turn: number, event: MovementEvent): T[] {
check(object(event) && (keys(event, 'amount,territory') || keys(event, 'amount,sector,territory')) &&
identifier(event.territory) && count(event.amount) && (!('sector' in event) ||
typeof event.sector === 'number' && Number.isSafeInteger(event.sector) &&
validLocation(event.territory, event.sector)),
'Retain the exact destination, any printed sector and zero to twenty actual physical ground-moving forces.');
const updates = new Map<T, T>();
for (const promise of liveMovementExpressionPromises(promises, player, turn)) {
const claim = readPromise(promise);
if (!movementExpressionTerritories(claim).includes(event.territory)) continue;
const old = promise.progress.find(entry => entry.territory === event.territory)?.maximum ?? 0;
const namedSector = event.sector !== undefined && movementExpressionSectors(claim).some(location =>
location.territory === event.territory && location.sector === event.sector);
const oldSector = namedSector ? promise.sectorProgress?.find(entry =>
entry.territory === event.territory && entry.sector === event.sector)?.maximum ?? 0 : 0;
const advanceTerritory = event.amount > old, advanceSector = namedSector && event.amount > oldSector;
if (!advanceTerritory && !advanceSector) continue;
const progress = promise.progress.map(entry => ({ ...entry }));
if (advanceTerritory) {
const entry = progress.find(entry => entry.territory === event.territory);
if (entry) entry.maximum = event.amount;
else progress.push({ territory: event.territory, maximum: event.amount });
}
const sectorProgress = promise.sectorProgress?.map(entry => ({ ...entry })) ?? [];
if (advanceSector && event.sector !== undefined) {
const entry = sectorProgress.find(item => item.territory === event.territory && item.sector === event.sector);
if (entry) entry.maximum = event.amount;
else sectorProgress.push({ territory: event.territory, sector: event.sector, maximum: event.amount });
}
const satisfied = evaluate(claim, progress, sectorProgress);
check(promise.answer || !satisfied,
'This ground movement conflicts with your whole Truthtrance movement No answer.');
updates.set(promise, { ...promise, progress,
...(promise.sectorProgress !== undefined || advanceSector ? { sectorProgress } : {}),
...(promise.answer && satisfied ? { fulfilled: true } : {}) });
}
return promises.map(promise => updates.get(promise) ?? promise);
}
export function validateMovementExpressionPromises(promises: readonly MovementExpressionPromise[], players: readonly string[]): void {
check(Array.isArray(promises), 'The ground-movement expression commitment record is invalid.');
const events = new Set<string>();
for (const promise of promises) {
const claim = readPromise(promise);
const satisfied = evaluate(claim, promise.progress, promise.sectorProgress ?? []);
check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.player) && identifier(promise.asker) &&
players.includes(promise.player) && players.includes(promise.asker) && promise.player !== promise.asker &&
(promise.answer || !satisfied) && (!promise.answer || promise.released || !!promise.fulfilled === satisfied),
'The ground-movement expression lost its event, participants or whole-answer progress.');
events.add(promise.event);
}
}