Files
Dune/game/movement-promises.ts
T
Quintine edbcbe28de Enforce Basic Truthtrance ground-movement promises
Add a structured future single-movement predicate and persistent authoring, feasible-answer and promise controls for bare native Basic active post-shipment turns. Yes requires one matching physical movement while possible; No survives every later Hajr/Ornithopter opportunity until actual turn closure. Matching physical commitment precedes disposal and arrival effects. Preserve compatible promises after opposing loss and reject voluntary destruction of the last owned completion.

Reuse the actual movement declaration and physical commitment in a focused owned-action search rather than a strategic shortlist. Retain typed groups, original two-group cohorts, held Hajr, both Ornithopter modes, city access and current Kulon bonus. Deterministic preparation on private clones retains physical cost semantics without inventing response/discard events in reachability. Existing native/optional/Advanced movement retains its actual controls.

Verify 17-file218/218, types/lint/build, seven original-action gameplay/control/table-rendering programmes and all four existing legal profiles. Original public classic, paid cache Ornithopter and native allowed-Kulon sources use only unopened positions and scalar lotteries; extra-card/force-loss edges are explicitly controlled. Repair nearby stale fixture continuations to acknowledge Semuta/battle outcomes and put pre-movement Hajr in Basic. Full modes, wider preparation/timing/module coverage, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
2026-10-24 14:32:16 +11:00

107 lines
5.2 KiB
TypeScript

import { TERRITORIES, territory } from './board';
import { FACTIONS } from './catalog';
import type { Game } from './engine';
/** One qualifying ground movement, never the sum of several smaller groups. */
export type MovementClaim = { territory: string; minimum: number };
export type MovementEvent = { territory: string; amount: number };
export type MovementPromise = MovementClaim & {
turn: number;
player: string;
asker: string;
answer: boolean;
fulfilled?: boolean;
released?: boolean;
};
export class MovementClaimError extends Error {}
function check(value: unknown, message: string): asserts value {
if (!value) throw new MovementClaimError(message);
}
/** Extract claim fields; the caller validates surrounding question/ledger keys. */
export function parseMovementClaim(value: unknown): MovementClaim {
check(value && typeof value === 'object' && !Array.isArray(value),
'Choose a valid ground-movement statement.');
const claim = value as Record<string, unknown>;
check(typeof claim.territory === 'string' &&
TERRITORIES.some(destination => destination.id === claim.territory),
'Choose a printed ground-movement destination.');
check(typeof claim.minimum === 'number' && Number.isSafeInteger(claim.minimum) &&
claim.minimum >= 1 && claim.minimum <= 20,
'Choose a minimum of one to twenty physical forces.');
return { territory: claim.territory, minimum: claim.minimum };
}
export function validMovementClaim(value: unknown): value is MovementClaim {
try { parseMovementClaim(value); return true; }
catch (error) {
if (error instanceof MovementClaimError) return false;
throw error;
}
}
export function movementClaimText(claim: MovementClaim): string {
return `make a ground movement of at least ${claim.minimum} physical forces to ${territory(claim.territory).name}`;
}
export function movementMatches(claim: MovementClaim, event: MovementEvent | null): boolean {
return event !== null && event.territory === claim.territory &&
Number.isSafeInteger(event.amount) && event.amount >= claim.minimum && event.amount <= 20;
}
export function liveMovementPromises<T extends MovementPromise>(
promises: readonly T[], player: string, turn: number,
): T[] {
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
/** The caller supplies the relevant current promises. Yes is existential: a
* nonmatching event may be followed by another ordinary movement opportunity. */
export function movementEventAllowed(
promises: readonly MovementPromise[], event: MovementEvent | null,
): boolean {
return promises.every(promise => promise.fulfilled || promise.released ||
promise.answer || !movementMatches(promise, event));
}
export function settleMovementEvent<T extends MovementPromise>(
promises: readonly T[], player: string, turn: number, event: MovementEvent | null,
): T[] {
const live = liveMovementPromises(promises, player, turn);
check(movementEventAllowed(live, event),
'This ground movement would break an accepted No answer.');
return promises.map(promise => live.includes(promise) && promise.answer && movementMatches(promise, event)
? { ...promise, fulfilled: true } : promise);
}
/** Close only the actual player's movement turn. No stays binding until here;
* ending early cannot evade an unfulfilled Yes, even if this event was skipped. */
export function finishMovementPromises<T extends MovementPromise>(
promises: readonly T[], player: string, turn: number,
): T[] {
const live = liveMovementPromises(promises, player, turn);
check(live.every(promise => !promise.answer),
'Complete your accepted ground-movement promise before ending movement.');
return promises.map(promise => live.includes(promise) ? { ...promise, fulfilled: true } : promise);
}
/** Prototype profile/timing only. Actual route feasibility stays authoritative
* in the engine. Truthtrance and an ordinary active Ornithopter may remain. */
export function movementPromiseTargetAvailable(g: Game, player: string): boolean {
const target = g.players.find(candidate => candidate.id === player);
return g.status === 'playing' && g.advanced === false && g.phase === 5 &&
g.active === player && !!target && (target.shipped || target.moved > 0) &&
!!g.movementRemaining?.includes(player) &&
g.players.length >= 2 && g.players.length <= 6 &&
g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
new Set(g.expansions).size === g.expansions.length &&
g.players.every(candidate => {
const family = FACTIONS.find(entry => entry.id === candidate.faction)?.expansion;
return family === 'base' || (!!family && g.expansions.includes(family));
}) &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
!g.strongholdCards && !g.discoveryEnabled && !g.discoveries &&
!g.discoveryStash && !g.greatMaker && !g.ecazTreachery &&
!g.response && !g.decision && !g.phaseOpening &&
!g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingGuildTransport &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingArrivalOverlap &&
!g.pendingArrivalReaction?.length && !g.pendingFremenMove && !g.pendingIxMove &&
!g.pendingChoamMove && !g.pendingTreacheryDiscard;
}