Allow clean active Basic ground-movement questions before or after shipment. Enumerate ordinary reserve amounts, native physical allocations, destinations and pledged payment splits through the actual declaration and shared physical commit. Detached projection stops before income/arrival effects and never counts shipment as ground fulfillment. Joint movement No and shipment Yes retain a legal next action rather than invalid End guidance. Preserve exact pending ordinary reserve and native Guild permission/rate declarations when checking the remaining route. Voluntary wrong destination, force splitting or fee loss cannot release Yes through an opponent response. Guard unsupported sources before full speculative continuation. Existing live payment, cancellation, income and entry paths remain. Persistent busy-safe controls display reinforcement and both obligations; all four existing profiles use the same legal witness without strategy refinement. Verify 19 files165/165, types/lint/build, six original-action gameplay/control/table-rendering programmes and four profiles with16 accepted actions. Genuine sources19/37/24/19 retain classic Arrakeen10 reinforced to11, native zero-board Emperor/CHOAM starts and original Guild5/15/5 reserve/cross transport. Second-card/counter cases explicitly transfer conserved originals. Bounded review repaired source guarding, funding completeness and exact pending-frame behavior. Stop an observed expensive search, reorder complete candidates by useful proximity and conservatively prune dead-end final moves; twelve new engine cases pass in3.2s. Correct a display-template lint issue. Earlier phases/waiting turns, native transport/relocation preparation, No-Field/module sources, broader interactions, full modes, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation.
107 lines
5.2 KiB
TypeScript
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 &&
|
|
!!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;
|
|
}
|