Queue a committed Terror entry whose arrival also opened its own
interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor
choice, or Fremen storm protection — in the additive
Game.pendingArrivalReaction list, and open the oldest queued entry from
settleAutomaticContinuations once nothing is pending. Payment, force
transfer and the original movement never replay; a Terror entry already in
flight and every Ambassador combination still reject.
Both withdrawn mixed E3 programme pairs are green: Moritani 9/9 and Ecaz
14/14, stable across 20 consecutive full-file runs. Their recorded resume
constraints became real fixture repairs, and the two restored consumer
omissions were corrected against the engine rather than re-pinned.
Repair pre-existing failures inherited at 45e6084 against current engine
behavior: stale Discovery and module gates in the native skills, Ix and
expansion-faction prototypes, the advanced Moritani skill boundary, the
Richese Nexus betrayal sale-credit expectation, one incidental gate
message and one flaky Collection-readiness assertion that reproduced at
HEAD.
Offline suite 7,793/7,793 with typecheck and lint clean. No game, seat,
saved-state, deployment, public-start or played-save conversion changes.
819 lines
28 KiB
TypeScript
819 lines
28 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { runInNewContext } from 'node:vm';
|
|
import { webcrypto } from 'node:crypto';
|
|
import ts from 'typescript';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Game,
|
|
type Action,
|
|
} from '../game/engine';
|
|
import { botActions, runBots } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { baseDeck } from '../game/cards';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { TERRITORIES } from '../game/board';
|
|
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
|
|
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
|
import {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
|
|
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
/** Test-only observation of the unchanged production inner dispatcher, before
|
|
* its public wrapper drains automatic continuations. No runtime export or gate
|
|
* is changed, and all resumed actions use the real production exports. */
|
|
const observed: {
|
|
applyActionInner?: (g: Game, id: string, a: Action) => Game;
|
|
finishResponse?: (g: Game, canceled: boolean) => void;
|
|
} = {};
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
|
|
'\nexport { applyActionInner, finishResponse };\n',
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports: observed,
|
|
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
|
|
crypto: webcrypto,
|
|
structuredClone,
|
|
TextEncoder,
|
|
JSON,
|
|
},
|
|
);
|
|
function inner(g: Game, id: string, a: Action) {
|
|
const before = structuredClone(g);
|
|
const result = reload(observed.applyActionInner!(g, id, a));
|
|
assert.deepEqual(g, before);
|
|
return result;
|
|
}
|
|
const ORNI = 'richese-ornithopter',
|
|
ORIGIN = 'imperial_basin:10';
|
|
function fixture(faction: FactionId = 'richese', advanced = false) {
|
|
const g = createGame(
|
|
'ORNIFRAME',
|
|
newPlayer('p', 'Pilot', faction),
|
|
advanced,
|
|
['choam', 'ecaz'],
|
|
);
|
|
g.players.push(
|
|
newPlayer('q', 'Observer', 'atreides'),
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
active: 'p',
|
|
order: ['p', 'q', 'e'],
|
|
movementRemaining: ['p', 'q', 'e'],
|
|
storm: 18,
|
|
deck: baseDeck(),
|
|
richeseCache: richeseCards(),
|
|
});
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 10;
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
}
|
|
hold(g, 'p', 'Ornithopter');
|
|
player(g, 'p').forces = { [ORIGIN]: 3 };
|
|
player(g, 'p').reserves = 17;
|
|
return g;
|
|
}
|
|
function hold(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);
|
|
player(g, id).hand.push(card);
|
|
return card.id;
|
|
}
|
|
const first = (extra: Partial<Action> = {}): Action => ({
|
|
type: 'move',
|
|
movementCard: ORNI,
|
|
ornithopter: 'twoGroups',
|
|
forces: { [ORIGIN]: 1 },
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
...extra,
|
|
});
|
|
const final = (g: Game, extra: Partial<Action> = {}): Action => ({
|
|
type: 'move',
|
|
ornithopterEvent: g.ornithopter!.event,
|
|
forces: { [ORIGIN]: 1 },
|
|
territory: 'carthag',
|
|
sector: 11,
|
|
...extra,
|
|
});
|
|
function receipt(g: Game) {
|
|
const c = g.pendingTreacheryDiscard!.continuation;
|
|
if (c.kind !== 'ornithopterDiscard') throw Error('Missing retired flight');
|
|
return c;
|
|
}
|
|
function recover(g: Game) {
|
|
const before = structuredClone(g),
|
|
done = normalizeAutomaticGame(reload(g));
|
|
assert.deepEqual(g, before);
|
|
assert.equal(done.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(done))), reload(done));
|
|
return done;
|
|
}
|
|
function inventory(g: Game) {
|
|
const ids = [
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.richeseCache!,
|
|
...g.players.flatMap((p) => p.hand),
|
|
...(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, b) => a + b, 0),
|
|
20,
|
|
);
|
|
return ids;
|
|
}
|
|
|
|
void test('final fixed-range movement commits forces and the one played card before its arrival suffix', () => {
|
|
const initial = fixture(),
|
|
action = first({
|
|
ornithopter: 'range3',
|
|
territory: 'hagga_basin',
|
|
sector: 12,
|
|
});
|
|
const pending = inner(initial, 'p', action),
|
|
c = receipt(pending);
|
|
assert.equal(c.source, 'move');
|
|
assert.equal(c.flight.mode, 'range3');
|
|
assert.equal(c.flight.completed, 1);
|
|
assert.equal(pending.ornithopter, null);
|
|
assert.equal(player(pending, 'p').moved, 1);
|
|
assert.equal(player(pending, 'p').forces['hagga_basin:12'], 1);
|
|
assert.equal(player(pending, 'p').forces[ORIGIN], 2);
|
|
assert.equal(pending.pendingTreacheryDiscard!.batch.entries.length, 1);
|
|
assert.deepEqual(
|
|
pending.pendingTreacheryDiscard!.batch.entries.map((e) => [
|
|
e.card.id,
|
|
e.discardedBy,
|
|
e.publicFace,
|
|
]),
|
|
[[ORNI, 'p', true]],
|
|
);
|
|
const done = recover(pending);
|
|
assert.deepEqual(done.players, pending.players);
|
|
assert.deepEqual(done.discard, pending.discard);
|
|
// The independent initial action allocates a different flight event. Compare
|
|
// the resulting gameplay state rather than that private historical receipt.
|
|
const uninterrupted = applyAction(initial, 'p', action);
|
|
assert.deepEqual(done.players, uninterrupted.players);
|
|
assert.deepEqual(done.log, uninterrupted.log);
|
|
assert.deepEqual(inventory(done), inventory(initial));
|
|
});
|
|
|
|
void test('the first group stays in escrow, while the second group retirement restores exactly once', () => {
|
|
const initial = fixture(),
|
|
one = applyAction(initial, 'p', first());
|
|
assert.equal(one.ornithopter?.completed, 1);
|
|
assert.equal(one.discard.length, 0);
|
|
assert.equal(one.pendingTreacheryDiscard ?? null, null);
|
|
const action = final(one),
|
|
pending = inner(one, 'p', action);
|
|
assert.equal(receipt(pending).flight.completed, 2);
|
|
assert.equal(receipt(pending).source, 'move');
|
|
assert.equal(player(pending, 'p').moved, 2);
|
|
assert.equal(player(pending, 'p').forces[ORIGIN], 1);
|
|
const done = recover(pending);
|
|
assert.deepEqual(reload(done), reload(applyAction(one, 'p', action)));
|
|
assert.equal(done.discard.filter((c) => c.id === ORNI).length, 1);
|
|
assert.deepEqual(inventory(done), inventory(initial));
|
|
const replay = reload(done);
|
|
replay.pendingTreacheryDiscard = pending.pendingTreacheryDiscard;
|
|
assert.throws(() => normalizeAutomaticGame(replay));
|
|
});
|
|
|
|
void test('early end after one group retires escrow before advancing the movement queue', () => {
|
|
const one = applyAction(fixture(), 'p', first()),
|
|
pending = inner(one, 'p', { type: 'endMovement' });
|
|
assert.equal(receipt(pending).source, 'end');
|
|
assert.equal(receipt(pending).flight.completed, 1);
|
|
assert.equal(pending.active, 'p');
|
|
assert.deepEqual(pending.movementRemaining, ['p', 'q', 'e']);
|
|
const done = recover(pending);
|
|
assert.equal(done.active, 'q');
|
|
assert.deepEqual(done.movementRemaining, ['q', 'e']);
|
|
assert.deepEqual(done.players, pending.players);
|
|
assert.deepEqual(
|
|
reload(done),
|
|
reload(applyAction(one, 'p', { type: 'endMovement' })),
|
|
);
|
|
assert.deepEqual(inventory(done), inventory(one));
|
|
});
|
|
|
|
void test('a genuine Ixian speed cancellation leaves zero completed groups, then ending retires the played card once', () => {
|
|
let initial = fixture('ixians', true);
|
|
player(initial, 'p').elites = {
|
|
forces: { [ORIGIN]: 1 },
|
|
reserves: 6,
|
|
tanks: 0,
|
|
revived: 0,
|
|
};
|
|
const karama = hold(initial, 'e', 'Karama');
|
|
initial = applyAction(
|
|
initial,
|
|
'p',
|
|
first({
|
|
eliteForces: { [ORIGIN]: 1 },
|
|
territory: 'hagga_basin',
|
|
sector: 12,
|
|
}),
|
|
);
|
|
assert.equal(initial.response?.kind, 'ixMovement');
|
|
const event = initial.ornithopter!.event;
|
|
initial = applyAction(initial, 'e', {
|
|
type: 'card',
|
|
mode: 'cancel',
|
|
card: karama,
|
|
});
|
|
assert.equal(initial.ornithopter!.event, event);
|
|
assert.equal(initial.ornithopter!.completed, 0);
|
|
assert.equal(initial.pendingTreacheryDiscard ?? null, null);
|
|
assert.equal(player(initial, 'p').moved, 0);
|
|
const pending = inner(initial, 'p', { type: 'endMovement' });
|
|
assert.equal(receipt(pending).flight.completed, 0);
|
|
assert.equal(receipt(pending).source, 'end');
|
|
const done = recover(pending);
|
|
assert.deepEqual(done.players, pending.players);
|
|
assert.equal(done.active, 'q');
|
|
assert.deepEqual(
|
|
done.discard.map((c) => c.id),
|
|
[karama, ORNI],
|
|
);
|
|
inventory(done);
|
|
});
|
|
|
|
void test('an actual CHOAM movement decision can produce the final flight frame for a different action actor', () => {
|
|
let initial = fixture();
|
|
initial.players[1] = newPlayer('q', 'CHOAM', 'choam');
|
|
player(initial, 'q').forces = { 'hagga_basin:12': 1 };
|
|
player(initial, 'q').reserves = 19;
|
|
initial = applyAction(
|
|
initial,
|
|
'p',
|
|
first({ ornithopter: 'range3', territory: 'hagga_basin', sector: 12 }),
|
|
);
|
|
assert.equal(initial.decision?.kind, 'choamMovement');
|
|
assert.equal(initial.ornithopter!.completed, 0);
|
|
const action: Action = { type: 'decision', decline: true },
|
|
pending = inner(initial, 'q', action);
|
|
assert.equal(receipt(pending).flight.player, 'p');
|
|
assert.equal(pending.pendingChoamMove, null);
|
|
assert.equal(
|
|
pending.pendingTreacheryDiscard!.batch.entries[0].discardedBy,
|
|
'p',
|
|
);
|
|
const done = recover(pending);
|
|
assert.deepEqual(reload(done), reload(applyAction(initial, 'q', action)));
|
|
inventory(done);
|
|
});
|
|
|
|
void test('Baliset can prevent a declared fixed flight while its zero-group early ending still disposes escrow', () => {
|
|
let initial = fixture();
|
|
initial.players[1] = newPlayer('q', 'CHOAM', 'choam');
|
|
player(initial, 'q').forces = { 'hagga_basin:12': 1 };
|
|
player(initial, 'q').reserves = 19;
|
|
const baliset = hold(initial, 'q', 'Baliset');
|
|
initial = applyAction(
|
|
initial,
|
|
'p',
|
|
first({ ornithopter: 'range3', territory: 'hagga_basin', sector: 12 }),
|
|
);
|
|
initial = applyAction(initial, 'q', {
|
|
type: 'card',
|
|
mode: 'choam',
|
|
card: baliset,
|
|
target: 'p',
|
|
territory: 'hagga_basin',
|
|
});
|
|
assert.equal(initial.ornithopter!.completed, 0);
|
|
assert.equal(player(initial, 'p').moved, 0);
|
|
const pending = inner(initial, 'p', { type: 'endMovement' }),
|
|
done = recover(pending);
|
|
assert.equal(receipt(pending).source, 'end');
|
|
assert.equal(player(done, 'p').forces[ORIGIN], 3);
|
|
assert.deepEqual(
|
|
done.discard.map((c) => c.id),
|
|
[baliset, ORNI],
|
|
);
|
|
assert.equal(done.active, 'q');
|
|
inventory(done);
|
|
});
|
|
|
|
void test('final flight recovery opens a real Terror arrival after retiring the card, without replaying movement', () => {
|
|
const initial = fixture();
|
|
initial.players[1] = newPlayer('q', 'Moritani', 'moritani');
|
|
hold(initial, 'p', 'Shield');
|
|
initial.moritaniTerror = createTerrorState(() => 0);
|
|
const token = initial.moritaniTerror.tokens.find(
|
|
(t) => t.kind === 'sabotage',
|
|
)!;
|
|
initial.moritaniTerror = placeTerror(
|
|
initial.moritaniTerror,
|
|
token.id,
|
|
'arrakeen',
|
|
1,
|
|
);
|
|
const action = first({ ornithopter: 'range3' }),
|
|
pending = inner(initial, 'p', action);
|
|
assert.equal(pending.pendingTerrorEntry ?? null, null);
|
|
assert.equal(pending.decision, null);
|
|
const done = recover(pending);
|
|
assert.equal(done.pendingTerrorEntry?.stage, 'offer');
|
|
assert.equal(done.pendingTerrorEntry?.cause, 'movement');
|
|
assert.equal(done.pendingTerrorEntry?.amount, 1);
|
|
assert.deepEqual(done.players, pending.players);
|
|
const finished = applyAction(done, 'q', { type: 'decision', reveal: true });
|
|
assert.equal(finished.pendingTerrorEntry, null);
|
|
assert.equal(player(finished, 'p').moved, 1);
|
|
assert.equal(finished.resolvedTreacheryDiscardSequence, 2);
|
|
assert.equal(finished.discard.filter((c) => c.id === ORNI).length, 1);
|
|
inventory(finished);
|
|
});
|
|
|
|
void test('final flight opens an actual Ambassador offer once after recovery', () => {
|
|
const initial = fixture();
|
|
initial.players[1] = newPlayer('q', 'Ecaz', 'ecaz');
|
|
const ambassadors = createAmbassadors(() => 0);
|
|
const chosen = ambassadors.tokens.find((t) => t.effect === 'emperor')!;
|
|
ambassadors.cohort = [
|
|
chosen,
|
|
...ambassadors.tokens.filter(
|
|
(t) => t.effect !== 'ecaz' && t.id !== chosen.id,
|
|
),
|
|
]
|
|
.slice(0, 5)
|
|
.map((t) => t.id);
|
|
for (const token of ambassadors.tokens)
|
|
token.zone =
|
|
token.effect === 'ecaz' || ambassadors.cohort.includes(token.id)
|
|
? 'supply'
|
|
: 'pool';
|
|
initial.ecazAmbassadors = placeAmbassador(ambassadors, chosen.id, {
|
|
turn: 1,
|
|
availableSpice: 20,
|
|
destination: {
|
|
id: 'arrakeen',
|
|
stronghold: true,
|
|
inStorm: false,
|
|
allowed: true,
|
|
},
|
|
}).state;
|
|
const pending = inner(initial, 'p', first({ ornithopter: 'range3' }));
|
|
assert.equal(pending.pendingAmbassador ?? null, null);
|
|
const done = recover(pending);
|
|
assert.equal(done.pendingAmbassador?.stage, 'offer');
|
|
assert.equal(done.pendingAmbassador?.entrant, 'p');
|
|
assert.equal(done.pendingAmbassador?.resume, 'none');
|
|
assert.deepEqual(done.players, pending.players);
|
|
const declined = applyAction(done, 'q', {
|
|
type: 'decision',
|
|
event: done.pendingAmbassador!.event,
|
|
decline: true,
|
|
});
|
|
assert.equal(declined.pendingAmbassador, null);
|
|
assert.equal(player(declined, 'p').moved, 1);
|
|
inventory(declined);
|
|
});
|
|
|
|
void test('same-territory sector movement retires its card without opening a new entry opportunity', () => {
|
|
const initial = fixture();
|
|
const territory = TERRITORIES.find(
|
|
(t) => t.type !== 'polar' && t.sectors.filter((s) => s !== 18).length >= 2,
|
|
)!;
|
|
const [from, to] = territory.sectors.filter((s) => s !== 18);
|
|
player(initial, 'p').forces = { [`${territory.id}:${from}`]: 3 };
|
|
const pending = inner(
|
|
initial,
|
|
'p',
|
|
first({
|
|
ornithopter: 'range3',
|
|
forces: { [`${territory.id}:${from}`]: 1 },
|
|
territory: territory.id,
|
|
sector: to,
|
|
}),
|
|
);
|
|
const done = recover(pending);
|
|
assert.equal(player(done, 'p').forces[`${territory.id}:${to}`], 1);
|
|
assert.equal(done.pendingTerrorEntry ?? null, null);
|
|
assert.equal(done.pendingAmbassador ?? null, null);
|
|
assert.equal(player(done, 'p').moved, 1);
|
|
assert.deepEqual(done.players, pending.players);
|
|
inventory(done);
|
|
});
|
|
|
|
void test('a final concealed marker move preserves the newly assigned event and never materializes reserves', () => {
|
|
const views = [];
|
|
for (const value of [0, 3, 5] as const) {
|
|
const initial = fixture(),
|
|
ids = ['zero-marker', 'three-marker', 'five-marker'];
|
|
player(initial, 'p').noField = deployRicheseNoField(
|
|
createRicheseNoField(ids),
|
|
{
|
|
tokenId: ids[[0, 3, 5].indexOf(value)],
|
|
controller: 'p',
|
|
location: { territory: 'imperial_basin', sector: 10 },
|
|
},
|
|
);
|
|
player(initial, 'p').noFieldEvent = 'marker-before';
|
|
const pending = inner(
|
|
initial,
|
|
'p',
|
|
first({
|
|
ornithopter: 'range3',
|
|
forces: {},
|
|
noField: ids[[0, 3, 5].indexOf(value)],
|
|
event: 'marker-before',
|
|
}),
|
|
);
|
|
const markerEvent = player(pending, 'p').noFieldEvent;
|
|
assert.notEqual(markerEvent, 'marker-before');
|
|
assert.equal(receipt(pending).movement!.noField, true);
|
|
assert.equal(receipt(pending).movement!.total, 1);
|
|
const done = recover(pending);
|
|
assert.equal(player(done, 'p').noFieldEvent, markerEvent);
|
|
assert.deepEqual(player(done, 'p').noField, player(pending, 'p').noField);
|
|
assert.equal(player(done, 'p').reserves, 17);
|
|
assert.deepEqual(player(done, 'p').forces, { [ORIGIN]: 3 });
|
|
const view = viewGame(pending, 'q');
|
|
for (const id of ids) assert.ok(!JSON.stringify(view).includes(id));
|
|
// These are independent genuine moves. Their fresh public event UUIDs
|
|
// differ independently of the concealed token's denomination.
|
|
assert.equal(view.richeseNoField!.event, markerEvent);
|
|
view.richeseNoField!.event = 'same-public-event';
|
|
views.push(view);
|
|
inventory(done);
|
|
}
|
|
assert.deepEqual(views[0], views[1]);
|
|
assert.deepEqual(views[1], views[2]);
|
|
});
|
|
|
|
void test('all profiles wait for retirement and JSON preserves private hands without exposing the completed cohort', () => {
|
|
let initial = fixture();
|
|
hold(initial, 'p', 'Shield');
|
|
initial = applyAction(initial, 'p', first());
|
|
const pending = inner(initial, 'p', final(initial));
|
|
for (const p of pending.players) {
|
|
const view = viewGame(pending, p.id);
|
|
assert.equal(view.automaticContinuationPending, true);
|
|
assert.equal(view.ornithopter, null);
|
|
assert.ok(!('pendingTreacheryDiscard' in view));
|
|
if (p.id !== 'p')
|
|
assert.equal(view.players.find((x) => x.id === 'p')!.hand, undefined);
|
|
for (const level of DIFFICULTIES) {
|
|
view.players.find((x) => x.id === p.id)!.bot = level;
|
|
assert.deepEqual(botActions(view), []);
|
|
}
|
|
}
|
|
const done = runBots(reload(pending), 0);
|
|
assert.deepEqual(
|
|
reload({ ...done, botsPending: undefined }),
|
|
reload({ ...recover(pending), botsPending: undefined }),
|
|
);
|
|
});
|
|
|
|
void test('a granted out-of-order Guild flight ends by removing only Guild from the original remaining queue', () => {
|
|
let initial = fixture('guild', true);
|
|
initial.order = ['q', 'p', 'e'];
|
|
initial.movementRemaining = ['q', 'p', 'e'];
|
|
initial.active = null;
|
|
initial.decision = {
|
|
kind: 'guildTiming',
|
|
player: 'p',
|
|
next: 'q',
|
|
following: 'q',
|
|
};
|
|
initial = applyAction(initial, 'p', { type: 'decision', take: true });
|
|
assert.equal(initial.active, 'p');
|
|
assert.deepEqual(initial.movementRemaining, ['q', 'p', 'e']);
|
|
initial = applyAction(initial, 'p', first());
|
|
const pending = inner(initial, 'p', { type: 'endMovement' }),
|
|
done = recover(pending);
|
|
assert.deepEqual(pending.movementRemaining, ['q', 'p', 'e']);
|
|
assert.equal(pending.active, 'p');
|
|
assert.deepEqual(done.movementRemaining, ['q', 'e']);
|
|
assert.equal(done.active, 'q');
|
|
inventory(done);
|
|
});
|
|
|
|
void test('an actual Sapho-last combined turn clears its protected queue marker only after flight retirement', () => {
|
|
let initial = fixture();
|
|
const sapho = hold(initial, 'p', 'Juice of Sapho');
|
|
const option = viewGame(initial, 'p').saphoOptions.find(
|
|
(o) => o.scope === 'movement' && o.mode === 'last',
|
|
);
|
|
assert.ok(option);
|
|
initial = applyAction(initial, 'p', { type: 'card', card: sapho, ...option });
|
|
for (const id of ['q', 'e']) {
|
|
assert.equal(initial.active, id);
|
|
initial = applyAction(initial, id, { type: 'endMovement' });
|
|
}
|
|
assert.equal(initial.active, 'p');
|
|
initial = applyAction(initial, 'p', first());
|
|
const pending = inner(initial, 'p', { type: 'endMovement' });
|
|
assert.equal(pending.saphoMovementLast?.player, 'p');
|
|
assert.deepEqual(pending.movementRemaining, ['p']);
|
|
const done = recover(pending);
|
|
assert.equal(done.saphoMovementLast, null);
|
|
assert.deepEqual(done.movementRemaining, []);
|
|
assert.equal(done.discard.filter((c) => c.id === ORNI).length, 1);
|
|
inventory(done);
|
|
});
|
|
|
|
void test('a paid Box during deferred CHOAM movement retires separately before the final Ornithopter frame', () => {
|
|
let initial = fixture();
|
|
initial.players[1] = newPlayer('q', 'CHOAM', 'choam');
|
|
player(initial, 'q').forces = { 'hagga_basin:12': 1 };
|
|
player(initial, 'q').reserves = 19;
|
|
const box = hold(initial, 'p', 'Nullentropy Box');
|
|
initial.discard.push(...initial.deck.splice(0, 3));
|
|
initial = applyAction(
|
|
initial,
|
|
'p',
|
|
first({ ornithopter: 'range3', territory: 'hagga_basin', sector: 12 }),
|
|
);
|
|
assert.equal(initial.decision?.kind, 'choamMovement');
|
|
initial = applyAction(initial, 'p', { type: 'card', card: box });
|
|
const boxFrame = inner(initial, 'p', {
|
|
type: 'decision',
|
|
event: initial.pendingNullentropy!.event,
|
|
card: initial.discard[0].id,
|
|
});
|
|
assert.equal(
|
|
boxFrame.pendingTreacheryDiscard!.continuation.kind,
|
|
'nullentropyDiscard',
|
|
);
|
|
assert.equal(boxFrame.ornithopter!.completed, 0);
|
|
const restored = recover(boxFrame);
|
|
assert.equal(restored.decision?.kind, 'choamMovement');
|
|
const retired = inner(restored, 'q', { type: 'decision', decline: true });
|
|
assert.equal(receipt(retired).source, 'move');
|
|
assert.equal(retired.pendingTreacheryDiscard!.sequence, 2);
|
|
assert.deepEqual(
|
|
retired.pendingTreacheryDiscard!.batch.entries.map((e) => e.card.id),
|
|
[ORNI],
|
|
);
|
|
const done = recover(retired);
|
|
assert.equal(player(done, 'p').moved, 1);
|
|
assert.equal(player(done, 'p').spice, 8);
|
|
assert.equal(done.resolvedTreacheryDiscardSequence, 2);
|
|
assert.equal(done.discard.filter((c) => c.id === box).length, 1);
|
|
inventory(done);
|
|
});
|
|
|
|
void test('retired flights reject altered movement, queue, custody and suspended obligations before any suffix', () => {
|
|
const firstGroup = applyAction(fixture(), 'p', first());
|
|
const states = [
|
|
inner(firstGroup, 'p', final(firstGroup)),
|
|
inner(firstGroup, 'p', { type: 'endMovement' }),
|
|
];
|
|
for (const valid of states) {
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.turn++;
|
|
},
|
|
(g) => {
|
|
g.phase = 6;
|
|
},
|
|
(g) => {
|
|
g.active = 'q';
|
|
},
|
|
(g) => {
|
|
g.treacheryDiscardSequence!++;
|
|
},
|
|
(g) => {
|
|
receipt(g).source = receipt(g).source === 'move' ? 'end' : 'move';
|
|
},
|
|
(g) => {
|
|
receipt(g).flight.event = 'stale';
|
|
},
|
|
(g) => {
|
|
receipt(g).flight.player = 'q';
|
|
},
|
|
(g) => {
|
|
receipt(g).flight.turn--;
|
|
},
|
|
(g) => {
|
|
receipt(g).flight.completed++;
|
|
},
|
|
(g) => {
|
|
receipt(g).flight.startingMove++;
|
|
},
|
|
(g) => {
|
|
receipt(g).flight.card.name = 'forged';
|
|
},
|
|
(g) => {
|
|
receipt(g).stateSignature = 'forged';
|
|
},
|
|
(g) => {
|
|
player(g, 'p').moved++;
|
|
},
|
|
(g) => {
|
|
player(g, 'p').forces[ORIGIN]++;
|
|
},
|
|
(g) => {
|
|
player(g, 'p').reserves++;
|
|
},
|
|
(g) => {
|
|
g.order.reverse();
|
|
},
|
|
(g) => {
|
|
g.movementRemaining!.reverse();
|
|
},
|
|
(g) => {
|
|
g.guildTimingLocked = true;
|
|
},
|
|
(g) => {
|
|
g.ornithopter = receipt(g).flight;
|
|
},
|
|
(g) => {
|
|
g.pendingTreacheryDiscard!.batch.cause = 'ornithopter:wrong';
|
|
},
|
|
(g) => {
|
|
g.pendingTreacheryDiscard!.batch.entries[0].publicFace = false;
|
|
},
|
|
(g) => {
|
|
g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = 'q';
|
|
},
|
|
(g) => {
|
|
g.deck.push(structuredClone(receipt(g).flight.card));
|
|
},
|
|
(g) => {
|
|
g.discard = g.discard.filter((c) => c.id !== ORNI);
|
|
},
|
|
(g) => {
|
|
receipt(g).resume.response = {
|
|
kind: 'guildIncome',
|
|
owner: 'q',
|
|
amount: 3,
|
|
passed: [],
|
|
};
|
|
},
|
|
];
|
|
for (const key of [
|
|
'pendingChoamMove',
|
|
'pendingIxMove',
|
|
'pendingFremenMove',
|
|
'pendingTerrorEntry',
|
|
'pendingExchange',
|
|
'battle',
|
|
'pendingNullentropy',
|
|
'response',
|
|
'decision',
|
|
'pendingKarama',
|
|
'phaseOpening',
|
|
])
|
|
mutations.push((g) => {
|
|
Object.assign(g, { [key]: {} });
|
|
});
|
|
if (receipt(valid).source === 'move')
|
|
mutations.push(
|
|
(g) => {
|
|
receipt(g).movement = null;
|
|
},
|
|
(g) => {
|
|
receipt(g).movement!.player = 'q';
|
|
},
|
|
(g) => {
|
|
receipt(g).movement!.total = 0;
|
|
},
|
|
(g) => {
|
|
receipt(g).movement!.origin = 'missing';
|
|
},
|
|
(g) => {
|
|
receipt(g).movement!.to = 'carthag';
|
|
receipt(g).movement!.sector = 18;
|
|
},
|
|
(g) => {
|
|
receipt(g).movement!.elite = 2;
|
|
},
|
|
(g) => {
|
|
receipt(g).movement!.noField = true;
|
|
},
|
|
);
|
|
else
|
|
mutations.push((g) => {
|
|
receipt(g).movement = {
|
|
player: 'p',
|
|
origin: 'imperial_basin',
|
|
to: 'arrakeen',
|
|
sector: 10,
|
|
total: 1,
|
|
elite: 0,
|
|
noField: false,
|
|
};
|
|
});
|
|
for (const mutate of mutations) {
|
|
const corrupt = reload(valid);
|
|
mutate(corrupt);
|
|
const old = structuredClone(corrupt);
|
|
assert.throws(() => normalizeAutomaticGame(corrupt));
|
|
assert.throws(() => applyAction(corrupt, 'p', { type: 'advanceBots' }));
|
|
assert.throws(() => viewGame(corrupt, 'p'));
|
|
assert.deepEqual(corrupt, old);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('ending without flight escrow never emits an empty Ornithopter discard frame', () => {
|
|
const initial = fixture();
|
|
const done = applyAction(initial, 'p', { type: 'endMovement' });
|
|
assert.equal(done.treacheryDiscardSequence ?? 0, 0);
|
|
assert.equal(done.pendingTreacheryDiscard ?? null, null);
|
|
assert.ok(player(done, 'p').hand.some((c) => c.id === ORNI));
|
|
assert.equal(done.active, 'q');
|
|
});
|
|
|
|
void test('a final flight with competing BG intrusion and Terror commits its retirement and defers the Terror entry', () => {
|
|
const initial = fixture('richese', true);
|
|
initial.players[1] = newPlayer('q', 'Moritani', 'moritani');
|
|
initial.players[2] = newPlayer('e', 'Bene Gesserit', 'beneGesserit');
|
|
player(initial, 'e').forces = { 'arrakeen:10': 1 };
|
|
player(initial, 'e').reserves = 19;
|
|
initial.moritaniTerror = createTerrorState(() => 0);
|
|
initial.moritaniTerror = placeTerror(
|
|
initial.moritaniTerror,
|
|
initial.moritaniTerror.tokens.find((t) => t.kind === 'sabotage')!.id,
|
|
'arrakeen',
|
|
1,
|
|
);
|
|
const before = structuredClone(initial),
|
|
action = first({ ornithopter: 'range3' });
|
|
const flown = applyAction(initial, 'p', action);
|
|
assert.deepEqual(initial, before, 'The authoritative input is not mutated.');
|
|
assert.equal(flown.decision?.kind, 'intrusion');
|
|
assert.equal(flown.pendingArrivalReaction?.[0]?.entrant, 'p');
|
|
assert.equal(flown.pendingArrivalReaction?.[0]?.territory, 'arrakeen');
|
|
assert.equal(flown.pendingArrivalReaction?.[0]?.amount, 1);
|
|
assert.equal(flown.pendingTerrorEntry ?? null, null,
|
|
'The Terror entry waits for the pending Intrusion choice.');
|
|
assert.equal(player(flown, 'p').moved, 1);
|
|
assert.deepEqual(player(flown, 'p').forces, { 'imperial_basin:10': 2, 'arrakeen:10': 1 });
|
|
assert.equal(flown.ornithopter ?? null, null);
|
|
inventory(flown);
|
|
// The same committed flight saves its real physical retirement continuation.
|
|
const saved = inner(initial, 'p', action);
|
|
assert.equal(saved.pendingTreacheryDiscard?.continuation?.kind, 'ornithopterDiscard');
|
|
assert.equal(player(saved, 'p').moved, 1);
|
|
assert.deepEqual(player(saved, 'p').forces, { 'imperial_basin:10': 2, 'arrakeen:10': 1 });
|
|
inventory(saved);
|
|
});
|
|
|
|
void test('automatic Fremen faction-speed allowance drains the final group frame through the response loop', () => {
|
|
const initial = fixture('fremen');
|
|
const one = applyAction(
|
|
initial,
|
|
'p',
|
|
first({ territory: 'hagga_basin', sector: 12 }),
|
|
);
|
|
assert.equal(one.response, null);
|
|
assert.equal(one.ornithopter!.completed, 1);
|
|
assert.equal(player(one, 'p').moved, 1);
|
|
assert.equal(one.pendingTreacheryDiscard ?? null, null);
|
|
const done = applyAction(
|
|
reload(one),
|
|
'p',
|
|
final(one, { territory: 'hagga_basin', sector: 12 }),
|
|
);
|
|
assert.equal(done.response, null);
|
|
assert.equal(done.pendingFremenMove, null);
|
|
assert.equal(done.ornithopter, null);
|
|
assert.equal(done.pendingTreacheryDiscard, null);
|
|
assert.equal(done.resolvedTreacheryDiscardSequence, 1);
|
|
assert.equal(player(done, 'p').moved, 2);
|
|
assert.equal(player(done, 'p').forces['hagga_basin:12'], 2);
|
|
assert.equal(player(done, 'p').forces[ORIGIN], 1);
|
|
assert.deepEqual(inventory(done), inventory(initial));
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(done))), reload(done));
|
|
});
|