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.
206 lines
7.0 KiB
TypeScript
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' });
|
|
}
|
|
});
|