Files
Dune/tests/karama-movement-preflight.test.ts
T
Quintine 903d861170 Prevent saved CHOAM movement arrival deadlocks
Preflight ordinary movement through the existing arrival quote before creating new Ornithopter escrow or opening Ixian, Fremen and CHOAM prevention windows. An explicit legacy CHOAM decline can return an uncommitted unsupported arrival for another choice without consuming movement or touching forces, spice, cards or tokens. Preserve an existing Ornithopter and retire it exactly once on a legal replacement move. Keep dedicated Ambassador relocation handling and Karama cancellation validation before cost; unrelated exceptions remain atomic. Simultaneous Ambassador/Terror priority still awaits the recorded material interpretation, with no invented ordering.

Add reusable offline faction-game samples with genuine Basic/Advanced setup, all four AI profiles, physical custody checks, JSON restorations, private traces and failed snapshots. Source-bound reports and input snapshot hashes make fresh and resumed samples repeatable; document that a resumed continuation starts a new seeded stream and does not prove input setup provenance.

Validation: independently reviewed fresh declaration, native cancellation, supported single arrivals, legacy retry and card custody tests. Two production SQLite tests cover authenticated private restoration, stale/duplicate/wrong-owner rejection, resource conservation and concurrent version races. Typecheck, lint, 4583 offline tests, production build and 43 HTTP tests passed. Retain the initial failed check log for the corrected numeric-sort lint issue.

All six fresh samples complete. CHOAM Basic 1054 actions/28 JSON restores, CHOAM Advanced 225/6, Ecaz Basic 91/2, Ecaz Advanced 523/14, all-three Basic 476/12, all-three Advanced 1303/35 with a turn-six CHOAM win. Combined Advanced has 46 guarded arrival candidates with legal alternatives. The exact prior 614-action failed snapshot separately finishes after 691 additional actions/18 restores and 39 guarded candidates. These are bounded paths, not complete faction or combination certification. Gameplay source hashes match the final checkpoint; later evidence-only documentation and recovery-test lint edits are explicit.

After the reported outage, all 632 opening games and original seat/recovery records were preserved (665 after HTTP tests); integrity and foreign keys passed. Existing Ecaz and Ixian browser seats restored their same private hands, resources and pending choices after reload. This is browser restoration evidence, not a staged arrival-return claim. The healthy server was reused; no restart, database reset, recurring automation or publication.

Verified source tree: 550221850f4802c41155f7bbc35d623965bc7db3330825636d50f26bb42868f8
2026-09-13 23:15:00 +10:00

677 lines
20 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
quoteCompletedMovementArrival,
MovementArrivalError,
type CompletedMovementArrivalInput,
} from '../game/karama-movement-preflight';
import {
applyAction,
createGame,
joinGame,
newPlayer,
normalizeAutomaticGame,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
function fixture(): CompletedMovementArrivalInput {
return {
advanced: true,
players: [
{ id: 'p', faction: 'emperor', ally: null, forces: {} },
{ id: 'b', faction: 'beneGesserit', ally: null, forces: {} },
{ id: 'e', faction: 'ecaz', ally: null, forces: {} },
{ id: 'm', faction: 'moritani', ally: null, forces: {} },
],
order: {
player: 'p',
origin: 'red_chasm',
to: 'broken_land',
advisors: false,
wantsFighters: false,
},
ambassadors: [],
terror: [],
controls: {
response: false,
decision: false,
pendingTerror: false,
pendingAmbassador: false,
paidBox: false,
},
flight: null,
};
}
const ambassador = {
zone: 'placed' as const,
location: 'broken_land',
effect: 'atreides' as const,
};
const terror = { status: 'placed' as const, location: 'broken_land' };
void test('a plain movement has no arrival reaction, and an eligible token opens only its own opportunity', () => {
const input = fixture();
assert.deepEqual(quoteCompletedMovementArrival(input), {
intrusion: false,
reaction: null,
retiresOrnithopter: false,
});
assert.equal(
quoteCompletedMovementArrival({ ...input, ambassadors: [ambassador] })
.reaction,
'ambassador',
);
assert.equal(
quoteCompletedMovementArrival({ ...input, terror: [terror] }).reaction,
'terror',
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
ambassadors: [ambassador],
terror: [terror],
}),
MovementArrivalError,
);
});
for (const exclusion of [
'owner',
'ally',
'matchingFaction',
'advisor',
] as const)
void test(`Ambassador ${exclusion} exclusion still permits the independent Terror opportunity`, () => {
const input = fixture();
const players = input.players.map((p) => ({ ...p }));
let order = { ...input.order };
if (exclusion === 'owner') order.player = 'e';
if (exclusion === 'ally') players.find((p) => p.id === 'e')!.ally = 'p';
if (exclusion === 'matchingFaction')
players.find((p) => p.id === 'p')!.faction = 'atreides';
if (exclusion === 'advisor') {
order = { ...order, player: 'b', advisors: true };
}
assert.equal(
quoteCompletedMovementArrival({
...input,
players,
order,
ambassadors: [ambassador],
terror: [terror],
}).reaction,
'terror',
);
});
for (const entrant of ['m', 'p'])
void test(`Terror exempts its ${entrant === 'm' ? 'owner' : 'ally'} while independent Ambassador entry stays eligible`, () => {
const input = fixture();
const players = input.players.map((p) =>
p.id === 'm' ? { ...p, ally: 'p' } : p,
);
assert.equal(
quoteCompletedMovementArrival({
...input,
players,
order: { ...input.order, player: entrant },
ambassadors: [ambassador],
terror: [terror],
}).reaction,
'ambassador',
);
});
void test('BG intrusion uses current opposing fighters, requires Advanced rules and excludes the mover itself', () => {
const input = fixture();
const players = input.players.map((p) =>
p.id === 'b' ? { ...p, forces: { 'broken_land:9': 2 } } : p,
);
assert.equal(
quoteCompletedMovementArrival({ ...input, players }).intrusion,
true,
);
assert.equal(
quoteCompletedMovementArrival({ ...input, players, advanced: false })
.intrusion,
false,
);
assert.equal(
quoteCompletedMovementArrival({
...input,
players,
order: { ...input.order, player: 'b' },
}).intrusion,
false,
);
const advisors = players.map((p) =>
p.id === 'b' ? { ...p, advisors: { broken_land: {} } } : p,
);
assert.equal(
quoteCompletedMovementArrival({ ...input, players: advisors }).intrusion,
false,
);
for (const tokens of [{ ambassadors: [ambassador] }, { terror: [terror] }])
assert.throws(
() => quoteCompletedMovementArrival({ ...input, players, ...tokens }),
/another arrival reaction/,
);
});
void test('mover advisor stance is the declared resulting stance, not its stale destination map', () => {
const input = fixture();
const players = input.players.map((p) =>
p.id === 'b' ? { ...p, advisors: { broken_land: {} } } : p,
);
const base = {
...input,
players,
ambassadors: [ambassador],
order: { ...input.order, player: 'b' },
};
assert.equal(quoteCompletedMovementArrival(base).reaction, 'ambassador');
assert.equal(
quoteCompletedMovementArrival({
...base,
order: { ...base.order, advisors: true },
}).reaction,
null,
);
// A pending fighter flip leaves the advisor stance in place until allowed.
assert.equal(
quoteCompletedMovementArrival({
...base,
order: { ...base.order, advisors: true, wantsFighters: true },
}).reaction,
null,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...base,
terror: [terror],
order: { ...base.order, advisors: true, wantsFighters: true },
}),
/Terror combined/,
);
});
void test('same-territory movement still reports intrusion but does not reopen Ambassador or Terror entry', () => {
const input = fixture();
const players = input.players.map((p) =>
p.id === 'b' ? { ...p, forces: { 'broken_land:9': 1 } } : p,
);
assert.deepEqual(
quoteCompletedMovementArrival({
...input,
players,
order: { ...input.order, origin: 'broken_land', wantsFighters: true },
ambassadors: [ambassador],
terror: [terror],
controls: {
...input.controls,
response: true,
decision: true,
pendingTerror: true,
pendingAmbassador: true,
},
}),
{ intrusion: true, reaction: null, retiresOrnithopter: false },
);
});
void test('current controls and the move-created fighter response are checked without inventing a global pending-parent ban', () => {
const input = fixture();
for (const flag of [
'response',
'decision',
'pendingTerror',
'pendingAmbassador',
] as const)
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
ambassadors: [ambassador],
controls: { ...input.controls, [flag]: true },
}),
/Ambassadors combined/,
);
for (const flag of ['response', 'decision'] as const)
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
terror: [terror],
controls: { ...input.controls, [flag]: true },
}),
/Terror combined/,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
terror: [terror],
controls: { ...input.controls, response: true, pendingTerror: true },
}),
/Resolve the pending Terror entry first/,
);
assert.equal(
quoteCompletedMovementArrival({
...input,
terror: [terror],
controls: { ...input.controls, pendingAmbassador: true },
}).reaction,
'terror',
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
ambassadors: [ambassador],
order: { ...input.order, wantsFighters: true },
}),
/Ambassadors combined/,
);
assert.equal(
quoteCompletedMovementArrival({
...input,
controls: {
...input.controls,
response: true,
decision: true,
pendingTerror: true,
pendingAmbassador: true,
},
}).reaction,
null,
);
});
void test('unplaced, removed, wrong-territory or unowned markers create no arrival opportunity', () => {
const input = fixture();
assert.equal(
quoteCompletedMovementArrival({
...input,
ambassadors: [
{ ...ambassador, zone: 'supply', location: null },
{ ...ambassador, location: 'red_chasm' },
],
terror: [
{ ...terror, status: 'removed' },
{ ...terror, location: 'red_chasm' },
],
}).reaction,
null,
);
assert.equal(
quoteCompletedMovementArrival({
...input,
players: input.players.filter((p) => p.id !== 'e' && p.id !== 'm'),
ambassadors: [ambassador],
terror: [terror],
}).reaction,
null,
);
});
void test('paid Box prevents only actual Ornithopter retirement: first two-groups move remains distinct from its final move', () => {
const input = fixture();
const controls = { ...input.controls, paidBox: true };
assert.equal(
quoteCompletedMovementArrival({ ...input, controls }).retiresOrnithopter,
false,
);
assert.equal(
quoteCompletedMovementArrival({
...input,
controls,
flight: { player: 'p', mode: 'twoGroups', completed: 0 },
}).retiresOrnithopter,
false,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
controls,
flight: { player: 'p', mode: 'twoGroups', completed: 1 },
}),
/Finish the paid search/,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
controls,
flight: { player: 'p', mode: 'range3', completed: 0 },
}),
/Finish the paid search/,
);
assert.equal(
quoteCompletedMovementArrival({
...input,
controls,
flight: { player: 'b', mode: 'range3', completed: 0 },
}).retiresOrnithopter,
false,
);
assert.equal(
quoteCompletedMovementArrival({
...input,
flight: { player: 'p', mode: 'twoGroups', completed: 1 },
}).retiresOrnithopter,
true,
);
});
void test('arrival quotes are repeatable and immutable without RNG, events, or reading private cards and marker value', (t) => {
const input = fixture();
const before = structuredClone(input);
for (const p of input.players)
Object.defineProperty(p, 'hand', {
get() {
throw Error('Private hand read.');
},
});
Object.defineProperty(input.players[0], 'noField', {
get() {
throw Error(
'The mover marker value is irrelevant to arrival eligibility.',
);
},
});
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw Error('Randomness in pure quote.');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Event creation in pure quote.');
});
const first = quoteCompletedMovementArrival(input);
assert.deepEqual(quoteCompletedMovementArrival(input), first);
assert.deepEqual(JSON.parse(JSON.stringify(input)), before);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const source = 'imperial_basin:10';
function holdCard(g: Game, id: string, name: string) {
const pile = g.richeseCache?.some((c) => c.name === name)
? g.richeseCache
: g.deck;
const index = pile!.findIndex((c) => c.name === name);
assert.ok(index >= 0, name);
const card = pile!.splice(index, 1)[0];
seat(g, id).hand.push(card);
return card.id;
}
function physical(g: Game) {
const ids = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...(g.richeseCache ?? []),
...(g.ornithopter ? [g.ornithopter.card] : []),
]
.map((c) => c.id)
.sort();
assert.equal(new Set(ids).size, ids.length);
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0),
20,
);
return ids;
}
/** Only initial board/card supply is staged. Movement card selection, the
* CHOAM opportunity, and Baliset declaration are all actual public actions. */
function balisetFlight(mode: 'range3' | 'twoGroups', conflict = false) {
let g = createGame('KARAMAARRIVAL', newPlayer('c', 'CHOAM', 'choam'), true, [
'choam',
'ecaz',
]);
joinGame(g, newPlayer('p', 'Emperor', 'emperor'));
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('e', 'Ecaz', 'ecaz'));
joinGame(g, newPlayer('m', 'Moritani', 'moritani'));
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
order: ['p', 'c', 'b', 'e', 'm'],
active: 'p',
movementRemaining: ['p', 'c', 'b', 'e', 'm'],
deck: baseDeck(),
richeseCache: richeseCards(),
});
for (const p of g.players) {
p.hand = [];
p.spice = 10;
p.forces = {};
p.reserves = 20;
}
seat(g, 'p').forces[source] = 3;
seat(g, 'p').reserves = 17;
const territory = mode === 'range3' ? 'arrakeen' : 'carthag';
const sector = mode === 'range3' ? 10 : 11;
const destination = `${territory}:${sector}`;
seat(g, 'c').forces[destination] = 1;
seat(g, 'c').reserves = 19;
const orni = holdCard(g, 'p', 'Ornithopter');
const baliset = holdCard(g, 'c', 'Baliset');
const printed = holdCard(g, 'p', 'Karama');
const worthless = holdCard(g, 'b', 'Jubba Cloak');
const addLegacyConflict = () => {
g.ecazAmbassadors = createAmbassadors(() => 0);
g.ecazAmbassadors = placeAmbassador(
g.ecazAmbassadors,
g.ecazAmbassadors.tokens.find((t) => t.effect === 'ecaz')!.id,
{
turn: 1,
availableSpice: 10,
destination: {
id: territory,
stronghold: true,
allowed: true,
inStorm: false,
},
},
).state;
g.moritaniTerror = createTerrorState(() => 0);
g.moritaniTerror = placeTerror(
g.moritaniTerror,
g.moritaniTerror.tokens.find((t) => t.kind === 'sabotage')!.id,
territory,
1,
);
};
const inventory = physical(g);
if (mode === 'twoGroups') {
g = applyAction(g, 'p', {
type: 'move',
movementCard: orni,
ornithopter: mode,
forces: { [source]: 1 },
territory: 'arrakeen',
sector: 10,
});
assert.equal(g.ornithopter?.completed, 1);
assert.equal(g.decision, null);
g = applyAction(g, 'p', {
type: 'move',
ornithopterEvent: g.ornithopter!.event,
forces: { [source]: 1 },
territory,
sector,
});
} else {
g = applyAction(g, 'p', {
type: 'move',
movementCard: orni,
ornithopter: mode,
forces: { [source]: 1 },
territory,
sector,
});
}
assert.equal(g.decision?.kind, 'choamMovement');
assert.equal(seat(g, 'p').moved, mode === 'twoGroups' ? 1 : 0);
g = applyAction(g, 'c', {
type: 'card',
card: baliset,
mode: 'choam',
target: 'p',
territory,
});
assert.equal(g.response?.kind, 'choamWorthless');
assert.equal(g.pendingChoamWorthless?.movement, true);
// Fresh declarations now reject this topology before opening CHOAM. Model
// an already-persisted window (or changed topology) to retain cost preflight coverage.
if (conflict) addLegacyConflict();
assert.deepEqual(physical(g), inventory);
return { g, orni, baliset, printed, worthless, destination };
}
function allowConversion(initial: Game) {
let g = initial;
while (g.response?.kind === 'worthlessKarama') {
const p = g.players.find((p) => !g.response!.passed.includes(p.id));
assert.ok(p);
g = applyAction(g, p.id, { type: 'passResponse' });
}
return g;
}
for (const form of ['printed', 'worthless'] as const)
for (const mode of ['range3', 'twoGroups'] as const)
void test(`${form} cancellation of real Baliset rejects competing Ambassador and Terror arrival before cost (${mode})`, (t) => {
const { g, printed, worthless, orni, baliset } = balisetFlight(
mode,
true,
);
const before = structuredClone(g);
const inventory = physical(g);
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw Error('Rejected arrival preflight drew randomness.');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Rejected arrival preflight created an event.');
});
assert.throws(
() =>
applyAction(g, form === 'printed' ? 'p' : 'b', {
type: 'card',
mode: 'cancel',
card: form === 'printed' ? printed : worthless,
}),
/Ambassadors combined with another arrival reaction/,
);
assert.deepEqual(g, before);
assert.deepEqual(physical(g), inventory);
assert.equal(g.pendingKarama ?? null, null);
assert.equal(g.ornithopter?.card.id, orni);
assert.equal(
g.discard.some((c) =>
[printed, worthless, baliset, orni].includes(c.id),
),
false,
);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
for (const form of ['printed', 'worthless'] as const)
for (const mode of ['range3', 'twoGroups'] as const)
void test(`${form} cancellation of real Baliset completes the exact final ${mode} flight and retires escrow once`, () => {
const { g, printed, worthless, orni, baliset, destination } =
balisetFlight(mode);
const before = structuredClone(g);
const inventory = physical(g);
const spent = form === 'printed' ? printed : worthless;
let done = applyAction(g, form === 'printed' ? 'p' : 'b', {
type: 'card',
mode: 'cancel',
card: spent,
});
assert.deepEqual(g, before);
if (form === 'worthless') {
assert.equal(done.response?.kind, 'worthlessKarama');
assert.equal(done.ornithopter?.completed, mode === 'twoGroups' ? 1 : 0);
assert.equal(seat(done, 'p').forces[destination] ?? 0, 0);
done = allowConversion(reload(done));
}
assert.equal(seat(done, 'p').forces[destination], 1);
assert.equal(
seat(done, 'p').forces[source],
mode === 'twoGroups' ? 1 : 2,
);
assert.equal(seat(done, 'p').moved, mode === 'twoGroups' ? 2 : 1);
assert.equal(done.ornithopter, null);
assert.equal(done.pendingChoamMove, null);
assert.equal(done.pendingTreacheryDiscard, null);
assert.equal(done.discard.filter((c) => c.id === orni).length, 1);
assert.equal(done.discard.filter((c) => c.id === spent).length, 1);
assert.ok(seat(done, 'c').hand.some((c) => c.id === baliset));
assert.deepEqual(physical(done), inventory);
assert.deepEqual(
reload(normalizeAutomaticGame(reload(done))),
reload(done),
);
});
for (const form of ['printed', 'worthless'] as const)
void test(`${form} cancellation still accepts a real Baliset order that became illegal, without moving or retiring the unused flight`, () => {
const {
g: original,
printed,
worthless,
orni,
baliset,
destination,
} = balisetFlight('range3');
const g = reload(original);
// Saved-state perturbation, not a claimed legal action during the response:
// source units have returned to reserves. The current flight remains valid,
// but its declared group is unavailable and must take the existing fizzle.
seat(g, 'p').reserves += seat(g, 'p').forces[source];
delete seat(g, 'p').forces[source];
const before = structuredClone(g);
const inventory = physical(g);
const spent = form === 'printed' ? printed : worthless;
let done = applyAction(g, form === 'printed' ? 'p' : 'b', {
type: 'card',
mode: 'cancel',
card: spent,
});
assert.deepEqual(g, before);
if (form === 'worthless') done = allowConversion(reload(done));
assert.equal(seat(done, 'p').moved, 0);
assert.equal(seat(done, 'p').forces[destination] ?? 0, 0);
assert.equal(seat(done, 'p').reserves, 20);
assert.equal(done.ornithopter?.completed, 0);
assert.equal(done.ornithopter?.card.id, orni);
assert.equal(done.pendingChoamMove, null);
assert.equal(done.pendingChoamWorthless, null);
assert.equal(done.discard.filter((c) => c.id === spent).length, 1);
assert.equal(
done.discard.some((c) => c.id === orni),
false,
);
assert.ok(seat(done, 'c').hand.some((c) => c.id === baliset));
assert.deepEqual(physical(done), inventory);
assert.deepEqual(
reload(normalizeAutomaticGame(reload(done))),
reload(done),
);
});