Files
Dune/game/karama-movement-cancellation.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

536 lines
20 KiB
TypeScript

import { validAmbassadorResume } from './ambassador-resume';
import { validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound } from './discovery-entry';
import { ambassadorPhaseAllowed } from './ambassador-phase';
import {
validateGuildAmbassadorArrivalContext,
GuildAmbassadorContinuationError,
} from './guild-ambassador-continuation';
import type { Game, ResponseWindow } from './engine';
import {
gameTerritories,
location,
mobileRouteDistance,
splitLocation,
validLocation,
MOBILE_STRONGHOLD,
} from './board';
import { presenceAt } from './force-presence';
import { planetologistLeader } from './planetologist-movement';
import { validateAmbassadors } from './ecaz-ambassadors';
import {
homeworldMobileStrongholdMovementBlock,
homeworldSpiritualAdvisorLimit,
} from './homeworld-mobility';
export class MovementCancellationError extends Error {
constructor(message: string) {
super(message);
this.name = 'MovementCancellationError';
}
}
function requireContext(value: unknown, message: string): asserts value {
if (!value) throw new MovementCancellationError(message);
}
const integer = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
type Move = NonNullable<Game['pendingIxMove']>;
export type MovementCancellationQuote =
| { kind: 'guildTiming'; active: string }
| {
kind: 'ixMovement' | 'fremenMovement';
player: string;
turn: number;
move: number;
}
| { kind: 'mobileStronghold'; successor: 'beginStormTurn' }
| {
kind: 'advisorFlip';
successor:
| 'none'
| 'advisor'
| 'advisorBattle'
| 'movementTurn'
| 'discoveryEntry'
| 'ambassador';
};
function sectorKey(g: Game, value: unknown): value is string {
if (typeof value !== 'string') return false;
const loc = splitLocation(value);
return (
location(loc.territory, loc.sector) === value &&
validLocation(loc.territory, loc.sector) &&
gameTerritories(g).some((t) => t.id === loc.territory)
);
}
function territoryId(g: Game, value: unknown): value is string {
return (
typeof value === 'string' && gameTerritories(g).some((t) => t.id === value)
);
}
function queue(g: Game) {
const remaining = g.movementRemaining;
requireContext(
Array.isArray(remaining) &&
remaining.length > 0 &&
new Set(remaining).size === remaining.length &&
remaining.every(
(id) => typeof id === 'string' && g.players.some((p) => p.id === id),
),
'The canceled movement timing needs its current remaining player queue.',
);
return remaining;
}
function movementDeclaration(
g: Game,
response: ResponseWindow,
move: Move | null | undefined,
) {
const owner = g.players.find((p) => p.id === response.owner)!;
requireContext(
g.phase === 5 &&
g.active === owner.id &&
integer(owner.moved) &&
move &&
move.player === owner.id &&
sectorKey(g, location(move.to, move.sector)) &&
territoryId(g, move.origin) &&
response.location === location(move.to, move.sector) &&
response.amount === move.total &&
integer(move.total) &&
move.total > 0 &&
integer(move.elite) &&
move.elite <= move.total &&
typeof move.advisors === 'boolean' &&
typeof move.wantsFighters === 'boolean' &&
!move.ornithopterRange &&
Array.isArray(move.group) &&
record(move.eliteGroup),
'The canceled movement no longer matches its owner and declaration.',
);
requireContext(
move.group.every(
(entry) =>
Array.isArray(entry) &&
entry.length === 2 &&
sectorKey(g, entry[0]) &&
(move.origins ?? [move.origin]).includes(splitLocation(entry[0]).territory) &&
integer(entry[1]) &&
entry[1] > 0,
) &&
new Set(move.group.map(([key]) => key)).size === move.group.length &&
Object.entries(move.eliteGroup).every(
([key, n]) =>
integer(n) &&
move.group.some(([source, count]) => source === key && n <= count),
) &&
Object.values(move.eliteGroup).reduce((sum, n) => sum + n, 0) ===
move.elite &&
move.group.reduce((sum, [, n]) => sum + n, 0) + (move.noField ? 1 : 0) ===
move.total,
'The canceled movement has invalid declared physical groups.',
);
if (move.planetologist || move.origins) {
requireContext(move.planetologist &&
move.planetologist.leader === planetologistLeader(g, owner.id) &&
!move.noField && !move.ornithopterEvent && !move.discoveryFlight &&
(move.planetologist.mode === 'range'
? move.origins === undefined
: move.planetologist.mode === 'gather' && Array.isArray(move.origins) &&
move.origins.length === 2 && new Set(move.origins).size === 2 &&
move.origins[0] === move.origin &&
JSON.stringify([...new Set(move.group.map(([key]) => splitLocation(key).territory))]) === JSON.stringify(move.origins)),
'The canceled Planetologist movement lost its skilled leader or exact origins.');
}
if (move.noField)
requireContext(
record(move.noField) &&
typeof move.noField.tokenId === 'string' &&
!!move.noField.tokenId &&
typeof move.noField.event === 'string' &&
!!move.noField.event &&
sectorKey(g, move.noField.from) &&
splitLocation(move.noField.from).territory === move.origin,
'The canceled movement has an invalid declared No-Field marker.',
);
// No custody/route trial here: canceled Ix movement only sets a block and
// keeps its forces in place. Fremen's pre-existing canonical order validator
// must additionally run in the engine before either form spends its cost.
}
export type AmbassadorRelocationContext = Pick<
Game,
| 'status'
| 'turn'
| 'phase'
| 'players'
| 'pendingAmbassador'
| 'ecazAmbassadors'
| 'homeworlds'
| 'homeworldRevivalReturn'
| 'homeworldRevivalProgress'
| 'homeworldVictoryReinforcement'
| 'lastBattleContext'
>;
/** Historical receipt only: a child may already have changed alliances, forces
* or marker custody. Never re-run the relocation or require surviving units. */
export function validateAmbassadorRelocationContext(
g: AmbassadorRelocationContext,
event: unknown,
next: 'intrusion' | 'terror' | 'finish',
) {
const entry = g.pendingAmbassador;
const order = entry?.relocation?.order;
requireContext(
g.status === 'playing' &&
integer(g.turn) &&
g.turn > 0 &&
ambassadorPhaseAllowed(g) &&
Array.isArray(g.players) &&
new Set(g.players.map((p) => p.id)).size === g.players.length &&
entry &&
entry.stage === 'arrival' &&
entry.effect === 'fremen' &&
entry.turn === g.turn &&
entry.phase === g.phase &&
typeof event === 'string' &&
event.length > 0 &&
entry.event === event &&
['intrusion', 'terror', 'finish'].includes(next) &&
entry.relocation?.next === next &&
order &&
g.players.filter((p) => p.faction === 'ecaz').length === 1 &&
g.players.find((p) => p.id === entry.owner)?.faction === 'ecaz' &&
g.players.some((p) => p.id === entry.entrant) &&
g.players.some((p) => p.id === entry.beneficiary) &&
entry.entrant !== entry.owner &&
entry.entrant !== entry.beneficiary &&
order.player === entry.beneficiary &&
validLocation(entry.territory, entry.sector) &&
validAmbassadorResume(g, entry),
'The canceled arrival has no matching completed Ambassador relocation.',
);
requireContext(g.ecazAmbassadors, 'The arrival has no Ambassador inventory.');
try {
validateAmbassadors(g.ecazAmbassadors);
} catch {
throw new MovementCancellationError(
'The arrival has invalid Ambassador token custody.',
);
}
const token = g.ecazAmbassadors.tokens.find((t) => t.id === entry.token);
requireContext(
token &&
g.players.find((p) => p.id === entry.entrant)?.faction !== token.effect &&
((token.effect === 'fremen' && token.zone === 'used') ||
(token.effect === 'beneGesserit' &&
token.zone === 'removed' &&
Array.isArray(entry.copyChoices) &&
entry.copyChoices.includes('fremen'))),
'The arrival does not belong to its consumed Fremen Ambassador effect.',
);
const key = (value: unknown): value is string => {
if (typeof value !== 'string') return false;
const source = splitLocation(value);
return (
location(source.territory, source.sector) === value &&
validLocation(source.territory, source.sector)
);
};
requireContext(
typeof order.origin === 'string' &&
typeof order.to === 'string' &&
integer(order.sector) &&
validLocation(order.to, order.sector) &&
integer(order.total) &&
order.total > 0 &&
integer(order.elite) &&
order.elite <= order.total &&
typeof order.advisors === 'boolean' &&
typeof order.wantsFighters === 'boolean' &&
(!order.wantsFighters || order.advisors) &&
(order.lockedTurn === undefined || integer(order.lockedTurn)) &&
Array.isArray(order.group) &&
record(order.eliteGroup) &&
order.group.every(
(part) =>
Array.isArray(part) &&
part.length === 2 &&
key(part[0]) &&
splitLocation(part[0]).territory === order.origin &&
part[0] !== location(order.to, order.sector) &&
integer(part[1]) &&
part[1] > 0,
) &&
new Set(order.group.map(([source]) => source)).size ===
order.group.length &&
Object.entries(order.eliteGroup).every(
([source, n]) =>
integer(n) &&
order.group.some(([k, amount]) => source === k && n <= amount),
) &&
Object.values(order.eliteGroup).reduce((sum, n) => sum + n, 0) ===
order.elite &&
order.group.reduce((sum, [, n]) => sum + n, 0) +
(order.noField ? 1 : 0) ===
order.total,
'The completed Ambassador relocation has invalid typed force groups.',
);
if (order.noField)
requireContext(
record(order.noField) &&
g.players.find((p) => p.id === order.player)?.faction === 'richese' &&
typeof order.noField.tokenId === 'string' &&
!!order.noField.tokenId &&
typeof order.noField.event === 'string' &&
!!order.noField.event &&
key(order.noField.from) &&
splitLocation(order.noField.from).territory === order.origin &&
order.noField.from !== location(order.to, order.sector),
'The completed Ambassador relocation has an invalid historical marker.',
);
return order;
}
/** Pure cancellation prerequisites, never a movement or random-effect preview.
* A null result explicitly leaves advisor worm-ride/phase-ending continuations
* outside this bounded proof. Future advisor decisions are not pre-accepted. */
export function quoteMovementCancellation(
g: Game,
response: ResponseWindow,
): MovementCancellationQuote | null {
if (
![
'guildTiming',
'mobileStronghold',
'ixMovement',
'fremenMovement',
'advisorFlip',
].includes(response.kind)
)
return null;
requireContext(
g.status === 'playing' &&
integer(g.turn) &&
g.turn > 0 &&
Array.isArray(g.players),
'This movement cancellation has no current game context.',
);
const owner = g.players.find((p) => p.id === response.owner);
requireContext(owner, 'This movement cancellation has no seated owner.');
if (response.kind === 'guildTiming') {
const remaining = queue(g);
requireContext(
g.advanced &&
g.phase === 5 &&
owner.faction === 'guild' &&
g.active === null &&
!g.guildTimingLocked &&
!g.guildTimingGranted &&
remaining.includes(owner.id) &&
typeof response.take === 'boolean' &&
response.take !== (remaining[0] === owner.id) &&
owner.id !== g.saphoMovementLast?.player &&
remaining.some(
(id) => id !== owner.id && id !== g.saphoMovementLast?.player,
),
'This Guild timing cancellation no longer matches its pending out-of-order choice.',
);
return { kind: 'guildTiming', active: remaining[0] };
}
if (response.kind === 'ixMovement' || response.kind === 'fremenMovement') {
requireContext(
owner.faction === (response.kind === 'ixMovement' ? 'ixians' : 'fremen'),
'This faction does not own the canceled movement advantage.',
);
const pending = g.pendingFremenMove;
const move =
response.kind === 'ixMovement' ? g.pendingIxMove : pending?.order;
movementDeclaration(g, response, move);
if (response.kind === 'ixMovement')
requireContext(
move!.elite > 0 && response.elite === move!.elite,
'The canceled cyborg movement does not match its declared elite group.',
);
else
requireContext(
pending && pending.turn === g.turn && pending.move === owner.moved,
'This Fremen movement cancellation no longer matches its turn and move.',
);
return {
kind: response.kind,
player: owner.id,
turn: g.turn,
move: owner.moved,
};
}
if (response.kind === 'mobileStronghold') {
const blocked = homeworldMobileStrongholdMovementBlock(g, owner.id);
requireContext(!blocked, blocked ?? 'This stronghold cannot move.');
const pending = g.pendingMobileMove;
requireContext(
g.phase === 0 &&
g.turn > 1 &&
owner.faction === 'ixians' &&
pending &&
pending.player === owner.id &&
typeof pending.collect === 'boolean' &&
Array.isArray(pending.route) &&
pending.route.length > 1 &&
pending.route.every((key) => sectorKey(g, key)) &&
pending.route[0] === g.mobileStronghold?.location &&
response.location === pending.route.at(-1) &&
mobileRouteDistance(pending.route) <= 3 &&
gameTerritories(g).find(
(t) => t.id === splitLocation(pending.route.at(-1)!).territory,
)?.type !== 'stronghold' &&
presenceAt(owner, MOBILE_STRONGHOLD) > 0,
'This canceled mobile stronghold move has no matching original route and owner.',
);
// beginStormTurn clears per-turn uses and reads the old battle dialers. A
// missing Fremen storm card deliberately remains the real future shuffle.
requireContext(
Array.isArray(g.lastBattle) &&
g.lastBattle.every((id) => g.players.some((p) => p.id === id)) &&
(g.stormCard == null ||
(integer(g.stormCard) && g.stormCard >= 1 && g.stormCard <= 6)) &&
g.players.every(
(p) =>
Array.isArray(p.leaders) &&
p.leaders.every(
(l) =>
record(l) && (l.concealed === undefined || record(l.concealed)),
) &&
(!p.elites || record(p.elites)) &&
(!p.kwisatz || record(p.kwisatz)),
) &&
(!g.dukeVidal || record(g.dukeVidal.leader)),
'The canceled mobile move cannot safely begin its pending storm turn.',
);
return { kind: 'mobileStronghold', successor: 'beginStormTurn' };
}
requireContext(
g.advanced &&
owner.faction === 'beneGesserit' &&
territoryId(g, response.location) &&
typeof response.advisors === 'boolean' &&
(response.advisorResume === undefined ||
response.advisorResume === 'declaration' ||
response.advisorResume === 'wormRide' ||
response.advisorResume === 'discoveryEntry' ||
response.advisorResume === 'ambassador'),
'This advisor cancellation has no valid owner, territory or continuation.',
);
if (response.advisorResume === 'discoveryEntry') {
requireContext(g.phase === 0 && g.discoveryEnabled && g.discoveries && g.discoveryEntry &&
response.discoveryEntry && response.advisors === true &&
response.location === g.discoveryEntry.arrival?.destination &&
response.advisorAmbassadorEvent === undefined && response.advisorFollowup === undefined &&
response.advisorRemaining === undefined,
'The advisor cancellation has lost its Discovery entry continuation.');
try {
validateDiscoveryEntryRound({...g,discoveries:g.discoveries},g.discoveryEntry);
validateDiscoveryEntryArrivalChild(g.discoveryEntry,response.discoveryEntry);
} catch (error) {
throw new MovementCancellationError(error instanceof Error ? error.message : 'Invalid Discovery arrival.');
}
return {kind:'advisorFlip',successor:'discoveryEntry'};
}
if (response.advisorResume === 'ambassador') {
if (g.pendingAmbassador?.effect === 'guild') {
try {
const { order } = validateGuildAmbassadorArrivalContext(
g,
response.advisorAmbassadorEvent,
'terror',
);
requireContext(
response.advisors === true &&
owner.id !== order.player &&
response.location === order.territory &&
response.advisorFollowup === undefined &&
response.advisorRemaining === undefined,
'The canceled Intrusion does not match its Guild Ambassador shipment.',
);
} catch (error) {
if (error instanceof GuildAmbassadorContinuationError)
throw new MovementCancellationError(error.message);
throw error;
}
return { kind: 'advisorFlip', successor: 'ambassador' };
}
const order = validateAmbassadorRelocationContext(
g,
response.advisorAmbassadorEvent,
response.advisors ? 'terror' : 'intrusion',
);
requireContext(
response.location === order.to &&
order.origin !== order.to &&
response.advisorFollowup === undefined &&
response.advisorRemaining === undefined &&
(response.advisors
? owner.id !== order.player
: owner.id === order.player && order.advisors && order.wantsFighters),
'The canceled advisor change does not match its Ambassador arrival.',
);
return { kind: 'advisorFlip', successor: 'ambassador' };
}
requireContext(
response.advisorAmbassadorEvent === undefined,
'Only an Ambassador arrival may carry its event.',
);
if (response.advisorResume === 'wormRide') return null;
requireContext(
g.phase === 5,
'This advisor cancellation is outside its shipment and movement phase.',
);
if (response.advisorFollowup) {
requireContext(
response.advisorResume === undefined &&
response.advisors &&
record(response.advisorFollowup) &&
typeof response.advisorFollowup.shipment === 'string' &&
g.players.some((p) => p.id === response.advisorFollowup!.shipment) &&
sectorKey(g, response.advisorFollowup.destination) &&
integer(owner.reserves),
'The canceled advisor change has an invalid accompanying-shipment continuation.',
);
return {
kind: 'advisorFlip',
successor:
homeworldSpiritualAdvisorLimit(g, owner.id, 'polar_sink') > 0
? 'advisor'
: 'none',
};
}
if (response.advisorResume === 'declaration') {
requireContext(
!response.advisors &&
Array.isArray(response.advisorRemaining) &&
new Set(response.advisorRemaining).size ===
response.advisorRemaining.length &&
response.advisorRemaining.every(
(t) => territoryId(g, t) && t !== response.location,
),
'The canceled advisor declaration has an invalid remaining territory list.',
);
if (response.advisorRemaining.length)
return { kind: 'advisorFlip', successor: 'advisorBattle' };
// An empty queue can enter battle/phase completion; do not claim that wider
// chain here. Ordinary phase-opening advisor declarations have a queue.
if (Array.isArray(g.movementRemaining) && !g.movementRemaining.length)
return null;
queue(g);
return { kind: 'advisorFlip', successor: 'movementTurn' };
}
requireContext(
response.advisorRemaining === undefined,
'A direct advisor change has no remaining declaration list.',
);
return { kind: 'advisorFlip', successor: 'none' };
}