Files
Dune/game/karama-movement-preflight.ts
T
Quintine 5082c81353 Prototype Planetologist movement and Special-card battles
Connect capped extra range, two-origin gathering, Fremen cancellation and
saved replacement choices. Add human controls and actual first-choice AI
paths for all four profiles. Admit the exact nine base green Specials in
the actual skilled leader's weapon slot for +2 on survival, with mandatory
physical discard and no native utility effect. Reject stale dead-disc plans.

Independent movement/battle review closed after the saved-dead-disc fix.
84 focused tests passed. Two four-profile Basic/Advanced samples completed
(194/186 accepted actions, six JSON restorations each, zero rejected actions).
Basic used both movement branches; Advanced used added range. Browser genuine
setup/gather/refresh and a separate targeted Special/traitor-loss fixture passed.

Required verification: types, lint, 4435 offline tests, build and 43 HTTP tests
passed. First attempt stopped at missing explicit test promise markers;
corrected before the successful broad run. Final check/build/HTTP durations:
144157/6638/25048 ms. Source fingerprint (1260 files):
bb7be69cf3e925bf7338ea444f77279ed7b37faadc3c44ed7db01b941f27b861
Private report: checkpoints/20260913-planetologist/checks-final/report.json.

Preserved all 325 opening games and all original application/seat/recovery
records; 359 rooms after 34 new QA/HTTP rooms. SQLite quick_check ok and zero
foreign-key violations. Healthy existing server reused; no reset/restart.

Boundaries: eight other Leader Skills, enemy-only mixed BG gather response,
expansion roster/green-card/optional-module combinations remain unfinished.
Winning traitor caller discard remains a documented card-specific inference.
No mode or publication gates opened.

Efficiency measure: aggregate goal counter 67321978 -> 68710948 (1388970),
including context and coordination; not billing tokens or a savings claim.
One skill's three connected effects, selective implementation/research/review,
one interrupted lint attempt followed by the required complete verification.
2026-09-13 18:16:36 +10:00

146 lines
4.9 KiB
TypeScript

import type { FactionId } from './catalog';
import { fighterCount } from './advisors';
import { canTriggerAmbassador, type AmbassadorToken } from './ecaz-ambassadors';
import type { ForcePresence } from './force-presence';
import type { TerrorToken } from './moritani-terror';
import type { OrnithopterMode } from './ornithopter';
export class MovementArrivalError extends Error {
constructor(message: string) {
super(message);
this.name = 'MovementArrivalError';
}
}
export type MovementArrivalPlayer = Readonly<
ForcePresence & {
id: string;
faction: FactionId;
ally: string | null;
advisors?: Readonly<Record<string, { lockedTurn?: number }>>;
}
>;
export type CompletedMovementArrivalInput = Readonly<{
advanced: boolean;
players: readonly MovementArrivalPlayer[];
order: Readonly<{
player: string;
origin: string;
origins?: readonly string[];
to: string;
advisors: boolean;
wantsFighters: boolean;
}>;
ambassadors: readonly Readonly<
Pick<AmbassadorToken, 'zone' | 'location' | 'effect'>
>[];
terror: readonly Readonly<Pick<TerrorToken, 'status' | 'location'>>[];
/** Controls that will remain after the canceled parent is cleared. */
controls: Readonly<{
response: boolean;
decision: boolean;
pendingTerror: boolean;
pendingAmbassador: boolean;
paidBox: boolean;
}>;
/** Current flight, before this validated move increments completed. */
flight?: Readonly<{
player: string;
mode: OrnithopterMode;
completed: number;
}> | null;
}>;
export type CompletedMovementArrivalQuote = Readonly<{
intrusion: boolean;
reaction: 'ambassador' | 'terror' | null;
retiresOrnithopter: boolean;
}>;
/**
* Pure guards for a validated move's resulting arrival, before any forces move.
* The caller owns order/route/flight/cohort/marker/promise validation. An order
* rejected by resumeChoamMovement's existing catch is an accepted no-move and
* must not reach this helper. This does not settle controls, create events,
* traverse storms/worms, or preview effects on a cloned game.
*/
export function quoteCompletedMovementArrival(
input: CompletedMovementArrivalInput,
): CompletedMovementArrivalQuote {
const { order, controls, flight, players } = input;
const entrant = players.find((p) => p.id === order.player);
if (!entrant)
throw new MovementArrivalError('You are not seated at this table.');
const retiresOrnithopter =
!!flight &&
flight.player === entrant.id &&
flight.completed + 1 === (flight.mode === 'twoGroups' ? 2 : 1);
if (retiresOrnithopter && controls.paidBox)
throw new MovementArrivalError(
'Finish the paid search before completing the movement card.',
);
// Only the mover changes stance. Another BG faction's current fighters are
// untouched, so this is exactly intrusion() without placing or flipping.
const bg = players.find((p) => p.faction === 'beneGesserit');
const intrusion = !!(
input.advanced &&
bg &&
bg.id !== entrant.id &&
fighterCount(bg, order.to)
);
const base = { intrusion, retiresOrnithopter };
// Intrusion also runs between sectors of one territory; entry does not.
if ((order.origins ?? [order.origin]).every(origin => origin === order.to)) return { ...base, reaction: null };
const ecaz = players.find((p) => p.faction === 'ecaz');
const ambassador = input.ambassadors.find(
(t) => t.zone === 'placed' && t.location === order.to,
);
const triggersAmbassador = !!(
ecaz &&
ambassador &&
canTriggerAmbassador({
owner: ecaz.id,
ally: ecaz.ally,
entrant: entrant.id,
entrantFaction: entrant.faction,
advisors: entrant.faction === 'beneGesserit' && order.advisors,
effect: ambassador.effect,
})
);
const moritani = players.find((p) => p.faction === 'moritani');
const triggersTerror = !!(
moritani &&
entrant.id !== moritani.id &&
entrant.id !== moritani.ally &&
input.terror.some((t) => t.status === 'placed' && t.location === order.to)
);
const response = controls.response || order.wantsFighters;
const decision = controls.decision || intrusion;
if (triggersAmbassador) {
if (
response ||
decision ||
controls.pendingTerror ||
controls.pendingAmbassador ||
triggersTerror
)
throw new MovementArrivalError(
'Ambassadors combined with another arrival reaction are still being implemented. This entry has not been committed.',
);
return { ...base, reaction: 'ambassador' };
}
if (triggersTerror) {
if (controls.pendingTerror)
throw new MovementArrivalError('Resolve the pending Terror entry first.');
if (response || decision)
throw new MovementArrivalError(
'Terror combined with another arrival reaction is still being implemented. This entry has not been committed.',
);
return { ...base, reaction: 'terror' };
}
return { ...base, reaction: null };
}