Files
Dune/tests/grumman-selection-integrity.test.ts
T
Quintine 1db11a0187 Implement Grumman collection and private stacked Terror choices
Add conserved token collection with four bank spice, owner-only stack selection, recovery proofs, controls and AI. Preserve unresolved removal and expansion release gates. Verified 3712 offline cases, types, lint, production build, 40 HTTP cases, three-seat desktop/mobile play, and unchanged 3081 preexisting saved games.
2026-09-10 08:38:06 +10:00

206 lines
7.0 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 ts from 'typescript';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import { quoteGrummanCollectionAction } from '../game/grumman-collection';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function entered(victimCard = false) {
let g = createGame('STACKREVIEW', newPlayer('m', 'Moritani', 'moritani'));
g.players.push(
newPlayer('e', 'Entrant', 'emperor'),
newPlayer('a', 'Observer', 'atreides'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'e',
order: ['e', 'm', 'a'],
movementRemaining: ['e', 'm', 'a'],
deck: baseDeck(),
});
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.spice = 20;
p.hand = [];
}
g.players[1].elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
const initial = createTerrorState(() => 0);
const robbery = initial.tokens.find((t) => t.kind === 'robbery')!.id;
const sabotage = initial.tokens.find((t) => t.kind === 'sabotage')!.id;
g.moritaniTerror = placeTerror(initial, robbery, 'arrakeen', 1);
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
// Focused phase-five audit setup: the stack itself uses the physical quote;
// phase-seven creation/once-only award is covered by the collection suites.
g.moritaniTerror = quoteGrummanCollectionAction(g, 'm', {
mode: 'add',
token: sabotage,
destination: 'arrakeen',
}).state;
if (victimCard) g.players[1].hand.push(g.deck.shift()!);
g = applyAction(g, 'e', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 2,
});
assert.equal(g.pendingTerrorEntry?.stage, 'select');
return { g, robbery, sabotage };
}
function rejected(g: Game, action: Action) {
const before = reload(g);
assert.throws(() => viewGame(g, 'm'));
assert.throws(() => normalizeAutomaticGame(g));
assert.throws(() => applyAction(g, 'm', action));
assert.deepEqual(g, before);
}
void test('a selected actual stacked entry cannot rewind, delete its selection proof or switch token after JSON recovery', () => {
const { g: initial, sabotage, robbery } = entered();
const selected = applyAction(initial, 'm', {
type: 'decision',
token: sabotage,
});
assert.equal(selected.pendingTerrorEntry?.stage, 'offer');
assert.equal(selected.pendingTerrorEntry?.token, sabotage);
assert.deepEqual(normalizeAutomaticGame(reload(selected)), reload(selected));
assert.equal(viewGame(selected, 'm').terrorEntry?.kind, 'sabotage');
assert.equal('kind' in viewGame(selected, 'a').terrorEntry!, false);
assert.equal('candidates' in viewGame(selected, 'a').terrorEntry!, false);
for (const edit of [
(g: Game) => {
g.pendingTerrorEntry!.stage = 'select';
g.pendingTerrorEntry!.token = g.pendingTerrorEntry!.candidates![0];
delete g.pendingTerrorEntry!.selectionSignature;
},
(g: Game) => {
delete g.pendingTerrorEntry!.selectionSignature;
},
(g: Game) => {
g.pendingTerrorEntry!.token = robbery;
},
(g: Game) => {
delete g.pendingTerrorEntry!.candidates;
},
]) {
const g = reload(selected);
edit(g);
rejected(g, { type: 'decision', token: robbery });
}
});
void test('unselected physical stack tokens stay reserved before and after the chosen token is revealed', () => {
const { g: initial, sabotage, robbery } = entered();
const selected = applyAction(initial, 'm', {
type: 'decision',
token: robbery,
});
const revealed = applyAction(reload(selected), 'm', {
type: 'decision',
reveal: true,
});
assert.equal(revealed.pendingTerrorEntry?.stage, 'robbery');
assert.equal(
revealed.moritaniTerror!.tokens.find((t) => t.id === robbery)!.status,
'removed',
);
for (const source of [initial, selected, revealed]) {
const g = reload(source);
g.moritaniTerror!.tokens.find((t) => t.id === sabotage)!.location =
'carthag';
rejected(g, { type: 'decision', decline: true });
}
assert.doesNotThrow(() => viewGame(revealed, 'm'));
});
void test('an actual stacked Sabotage inner-dispatch discard restores its consumed selection while preserving every other token', () => {
const { g: initial, sabotage, robbery } = entered(true);
const arrived = applyAction(initial, 'm', {
type: 'decision',
token: sabotage,
});
const victimCard = arrived.players[1].hand[0].id;
// Capture the genuine durable batch before the public action wrapper drains
// it. This verifies saved-continuation restoration, not a claimed HTTP pause.
const observed: {
applyActionInner?: (g: Game, id: string, action: Action) => Game;
} = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
'\nexport { applyActionInner };\n',
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports: observed,
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
crypto,
structuredClone,
TextEncoder,
JSON,
},
);
const pending = reload(
observed.applyActionInner!(arrived, 'm', {
type: 'decision',
reveal: true,
}),
);
const continuation = pending.pendingTreacheryDiscard!.continuation;
assert.equal(continuation.kind, 'terrorDiscard');
if (continuation.kind !== 'terrorDiscard')
throw new Error('Missing actual Terror discard');
assert.equal(continuation.entry.stage, 'offer');
assert.equal(continuation.entry.token, sabotage);
assert.equal(
pending.moritaniTerror!.tokens.find((t) => t.id === sabotage)!.status,
'removed',
);
assert.doesNotThrow(() => viewGame(pending, 'm'));
const finished = normalizeAutomaticGame(reload(pending));
assert.equal(finished.pendingTreacheryDiscard, null);
assert.equal(finished.pendingTerrorEntry, null);
assert.equal(
finished.discard.filter((card) => card.id === victimCard).length,
1,
);
assert.equal(finished.players[1].hand.length, 0);
assert.equal(
finished.moritaniTerror!.tokens.find((t) => t.id === robbery)!.location,
'arrakeen',
);
assert.deepEqual(normalizeAutomaticGame(reload(finished)), reload(finished));
for (const tokenId of [robbery, sabotage]) {
const corrupted = reload(pending);
const token = corrupted.moritaniTerror!.tokens.find(
(t) => t.id === tokenId,
)!;
token.status = 'available';
token.location = null;
rejected(corrupted, { type: 'advanceBots' });
}
});