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.
146 lines
4.9 KiB
TypeScript
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 };
|
|
}
|