237 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|