457 lines
16 KiB
TypeScript
457 lines
16 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,
|
|
initializeHomeworldGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { territory, location } from '../game/board';
|
|
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
|
import type { ShipmentPromise } from '../game/shipment-promises';
|
|
|
|
const emperor = (g: Game) => g.players.find((p) => p.id === 'e')!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
|
|
// Genuine base setup, including physical dealing and traitor selection. Only
|
|
// phase/opportunity positioning below is staged; counters/cards are never minted.
|
|
function fixture(advanced = true) {
|
|
let g = createGame(
|
|
'HOMEWORLDACTIONS',
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
advanced,
|
|
);
|
|
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
|
g = applyAction(g, 'e', { type: 'homeworlds', enabled: true });
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = initializeHomeworldGameForAudit(g);
|
|
while (g.setupStage === 'traitors') {
|
|
const p = g.players.find((seat) => seat.traitorChoices.length)!;
|
|
g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
return movement(g);
|
|
}
|
|
function movement(g: Game) {
|
|
g.phase = 5;
|
|
g.phaseOpening = null;
|
|
g.response = null;
|
|
g.decision = null;
|
|
g.active = 'e';
|
|
g.movementRemaining = ['e', 'a'];
|
|
g.ready = [];
|
|
for (const p of g.players) {
|
|
p.shipped = false;
|
|
p.moved = 0;
|
|
}
|
|
homeworldGameIntegrity(g);
|
|
return g;
|
|
}
|
|
function action(
|
|
g: Game,
|
|
origin = 'homeworld:emperor',
|
|
normal = 2,
|
|
elite = 0,
|
|
): Action {
|
|
return {
|
|
type: 'emperorHomeworldMove',
|
|
event: viewGame(g, 'e').homeworldMove!.event,
|
|
origin,
|
|
normal,
|
|
elite,
|
|
};
|
|
}
|
|
function rejects(g: Game, actor: string, command: Action, pattern?: RegExp) {
|
|
const before = structuredClone(g);
|
|
if (pattern) assert.throws(() => applyAction(g, actor, command), pattern);
|
|
else assert.throws(() => applyAction(g, actor, command));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
function assertPrivate(g: Game) {
|
|
for (const p of g.players) {
|
|
const view = viewGame(reload(g), p.id);
|
|
for (const other of view.players.filter((seat) => seat.id !== p.id)) {
|
|
assert.equal(other.hand, undefined);
|
|
assert.equal(other.traitors, undefined);
|
|
}
|
|
if (p.id !== 'e') assert.equal(view.homeworldMove, null);
|
|
}
|
|
}
|
|
function hold(g: Game, effect: string, player = 'e') {
|
|
const p = g.players.find((seat) => seat.id === player)!;
|
|
const already = p.hand.find((card) => card.effect === effect);
|
|
if (already) return already;
|
|
const source = [
|
|
g.deck,
|
|
...g.players.filter((seat) => seat !== p).map((seat) => seat.hand),
|
|
].find((cards) => cards.some((card) => card.effect === effect));
|
|
assert.ok(source, `Physical ${effect} card exists in the dealt game.`);
|
|
const index = source.findIndex((card) => card.effect === effect);
|
|
// The genuine shuffle may deal the desired card to the other player. Swap
|
|
// custody with one of our dealt cards, preserving the deck and hand sizes.
|
|
const [card] = source.splice(index, 1, ...p.hand.splice(0, 1));
|
|
p.hand.push(card);
|
|
return card;
|
|
}
|
|
function ship(g: Game, extras: Record<string, unknown> = {}): Action {
|
|
return {
|
|
type: 'ship',
|
|
territory: 'false_wall_west',
|
|
sector: territory('false_wall_west').sectors[0],
|
|
amount: 1,
|
|
elite: 0,
|
|
...extras,
|
|
};
|
|
}
|
|
|
|
void test('held-card fixtures recover a unique card dealt to the opponent without changing physical inventory', () => {
|
|
for (const effect of ['hajr', 'ghola']) {
|
|
const g = fixture();
|
|
const inventory = () =>
|
|
[...g.deck, ...g.players.flatMap((p) => p.hand), ...g.discard].sort(
|
|
(a, b) => a.id.localeCompare(b.id),
|
|
);
|
|
const before = structuredClone(inventory());
|
|
const handSizes = g.players.map((p) => p.hand.length);
|
|
const deckSize = g.deck.length;
|
|
const card = hold(g, effect, 'a');
|
|
assert.equal(
|
|
emperor(g).hand.some((held) => held.id === card.id),
|
|
false,
|
|
);
|
|
assert.equal(hold(g, effect).id, card.id);
|
|
assert.equal(hold(g, effect).id, card.id);
|
|
assert.deepEqual(inventory(), before);
|
|
assert.deepEqual(
|
|
g.players.map((p) => p.hand.length),
|
|
handSizes,
|
|
);
|
|
assert.equal(g.deck.length, deckSize);
|
|
homeworldGameIntegrity(g);
|
|
}
|
|
});
|
|
|
|
void test('actual Emperor transfer preserves native totals, spends one move, seals private cards and rejects a replay after JSON', () => {
|
|
const g = fixture();
|
|
const before = structuredClone(g);
|
|
const command = action(g);
|
|
const next = applyAction(g, 'e', command);
|
|
assert.deepEqual(g, before);
|
|
assert.deepEqual(next.homeworlds!.custody!.salusa, { normal: 2, elite: 5 });
|
|
assert.equal(emperor(next).reserves, emperor(g).reserves);
|
|
assert.deepEqual(emperor(next).elites, emperor(g).elites);
|
|
assert.equal(emperor(next).spice, emperor(g).spice);
|
|
assert.equal(emperor(next).moved, 1);
|
|
assert.equal(emperor(next).shipped, true);
|
|
assert.deepEqual(next.movementRemaining, g.movementRemaining);
|
|
assert.equal(next.active, 'e');
|
|
assert.deepEqual(next.deck, g.deck);
|
|
assert.deepEqual(
|
|
next.players.map((p) => p.hand),
|
|
g.players.map((p) => p.hand),
|
|
);
|
|
homeworldGameIntegrity(next);
|
|
assertPrivate(next);
|
|
rejects(reload(next), 'e', command);
|
|
rejects(g, 'a', command);
|
|
rejects(g, 'e', { ...command, event: 'stale' }, /stale/);
|
|
rejects(g, 'e', { ...command, amount: 2 });
|
|
});
|
|
|
|
void test('a real Hajr grants a second transfer with a fresh event; no extra transfer or Basic transfer is admitted', () => {
|
|
let g = fixture();
|
|
const hajr = hold(g, 'hajr');
|
|
g = applyAction(g, 'e', { type: 'card', card: hajr.id });
|
|
assert.ok(g.hajr.includes('e'));
|
|
const first = action(g);
|
|
g = applyAction(g, 'e', first);
|
|
assert.equal(viewGame(g, 'e').homeworldMove!.remaining, 1);
|
|
rejects(g, 'e', first, /stale/);
|
|
const second = action(g, 'homeworld:emperor:salusa', 1, 2);
|
|
g = applyAction(reload(g), 'e', second);
|
|
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 1, elite: 3 });
|
|
assert.equal(emperor(g).moved, 2);
|
|
assert.equal(g.discard.filter((card) => card.id === hajr.id).length, 1);
|
|
rejects(g, 'e', action(g));
|
|
homeworldGameIntegrity(g);
|
|
const basic = fixture(false);
|
|
assert.equal(viewGame(basic, 'e').homeworldMove, null);
|
|
rejects(basic, 'e', { ...first, event: 'basic' });
|
|
});
|
|
|
|
void test('blocking control and stale physical-source mutations cannot spend the Homeworld movement', () => {
|
|
const base = fixture();
|
|
const command = action(base);
|
|
for (const mutate of [
|
|
(g: Game) => {
|
|
g.phaseOpening = { passed: [], initialize: false };
|
|
},
|
|
(g: Game) => {
|
|
g.phase = 4;
|
|
},
|
|
(g: Game) => {
|
|
g.active = 'a';
|
|
},
|
|
(g: Game) => {
|
|
g.homeworlds!.custody!.salusa!.normal = 1;
|
|
},
|
|
]) {
|
|
const g = reload(base);
|
|
mutate(g);
|
|
rejects(g, 'e', command);
|
|
}
|
|
// A genuine pending phase-opening interaction is visible as a blocked move.
|
|
const blocked = reload(base);
|
|
blocked.phaseOpening = { passed: [], initialize: false };
|
|
assert.match(
|
|
viewGame(blocked, 'e').homeworldMove!.blocked!,
|
|
/current interaction/,
|
|
);
|
|
});
|
|
|
|
void test('a later ordinary shipment requires exact dual-Homeworld sources, debits only those physical counters and cannot repeat', () => {
|
|
let g = fixture();
|
|
g = applyAction(g, 'e', action(g));
|
|
// Position the next unused turn, retaining the actual preceding transfer.
|
|
g = movement(reload(g));
|
|
g.turn++;
|
|
const before = structuredClone(g);
|
|
rejects(g, 'e', ship(g), /Choose.*Homeworld/);
|
|
for (const homeworldSources of [
|
|
{ 'homeworld:emperor:salusa': { normal: 2, elite: 0 } },
|
|
{ 'homeworld:atreides': { normal: 1, elite: 0 } },
|
|
{ 'homeworld:emperor:salusa': { normal: 0, elite: 1 } },
|
|
])
|
|
rejects(g, 'e', ship(g, { homeworldSources }));
|
|
const command = ship(g, {
|
|
homeworldSources: { 'homeworld:emperor:salusa': { normal: 1, elite: 0 } },
|
|
});
|
|
g = applyAction(g, 'e', command);
|
|
assert.equal(emperor(g).reserves, emperor(before).reserves - 1);
|
|
assert.equal(emperor(g).spice, emperor(before).spice - 2);
|
|
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 1, elite: 5 });
|
|
const key = location(
|
|
'false_wall_west',
|
|
territory('false_wall_west').sectors[0],
|
|
);
|
|
assert.equal(emperor(g).forces[key], 1);
|
|
assert.equal(emperor(g).moved, 0);
|
|
homeworldGameIntegrity(g);
|
|
assertPrivate(g);
|
|
rejects(reload(g), 'e', command);
|
|
});
|
|
|
|
function stormKilledTransferredSardaukar() {
|
|
let g = fixture();
|
|
g = applyAction(g, 'e', action(g, 'homeworld:emperor:salusa', 0, 1));
|
|
assert.equal(g.homeworlds!.custody!.salusa!.elite, 4);
|
|
g = movement(reload(g));
|
|
g = applyAction(
|
|
g,
|
|
'e',
|
|
ship(g, {
|
|
territory: 'the_great_flat',
|
|
sector: 15,
|
|
elite: 1,
|
|
homeworldSources: { 'homeworld:emperor': { normal: 0, elite: 1 } },
|
|
}),
|
|
);
|
|
assert.equal(g.homeworlds!.custody!.salusa!.elite, 4);
|
|
const sector = 15;
|
|
// Stage only the next Storm opportunity; both casualties and the subsequent
|
|
// revival are produced by actual dispatcher actions.
|
|
g.phase = 0;
|
|
g.active = null;
|
|
g.storm = sector - 1;
|
|
g.stormDialers = ['e', 'a'];
|
|
g.stormDials = {};
|
|
g.stormPending = null;
|
|
g.ready = [];
|
|
g = applyAction(g, 'e', { type: 'stormDial', amount: 1 });
|
|
g = applyAction(g, 'a', { type: 'stormDial', amount: 0 });
|
|
for (const id of ['e', 'a']) g = applyAction(g, id, { type: 'ready' });
|
|
assert.equal(emperor(g).tanks, 1);
|
|
assert.equal(emperor(g).elites!.tanks, 1);
|
|
assert.equal(g.homeworlds!.custody!.salusa!.elite, 4);
|
|
g.phase = 4;
|
|
g.phaseOpening = null;
|
|
g.ready = [];
|
|
return g;
|
|
}
|
|
|
|
void test('a Sardaukar transferred to Kaitain, shipped, and killed by actual storm dials revives on Salusa exactly once', () => {
|
|
let g = stormKilledTransferredSardaukar();
|
|
const before = structuredClone(g);
|
|
g = applyAction(reload(g), 'e', { type: 'revive', amount: 1, elite: 1 });
|
|
assert.equal(emperor(g).tanks, 0);
|
|
assert.equal(emperor(g).elites!.tanks, 0);
|
|
assert.equal(emperor(g).elites!.revived, 1);
|
|
assert.equal(emperor(g).reserves, emperor(before).reserves + 1);
|
|
assert.equal(
|
|
emperor(g).elites!.reserves,
|
|
emperor(before).elites!.reserves + 1,
|
|
);
|
|
assert.equal(g.homeworlds!.custody!.salusa!.elite, 5);
|
|
assert.equal(emperor(g).revived, 1);
|
|
homeworldGameIntegrity(g);
|
|
assertPrivate(g);
|
|
rejects(reload(g), 'e', { type: 'revive', amount: 1, elite: 1 });
|
|
});
|
|
|
|
// Observe the real production dispatcher at its existing saved-frame boundary.
|
|
// No production export or mutation is introduced for these recovery assertions.
|
|
let privateEngine:
|
|
| {
|
|
applyActionInner: typeof applyAction;
|
|
findShipmentCompletion: (
|
|
g: Game,
|
|
p: Game['players'][number],
|
|
promises: ShipmentPromise[],
|
|
) => { actions: Action[] } | null;
|
|
}
|
|
| undefined;
|
|
function observedEngine() {
|
|
if (!privateEngine) {
|
|
const exports = {};
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
|
|
'\nexport { applyActionInner, findShipmentCompletion };\n',
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports,
|
|
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
|
|
structuredClone,
|
|
JSON,
|
|
TextEncoder,
|
|
crypto: webcrypto,
|
|
},
|
|
);
|
|
privateEngine = exports as NonNullable<typeof privateEngine>;
|
|
}
|
|
return privateEngine;
|
|
}
|
|
|
|
void test('actual Ghola retirement binds Homeworld allocation, restores JSON once, and rejects a same-total Salusa rewrite', () => {
|
|
const g = stormKilledTransferredSardaukar();
|
|
const ghola = hold(g, 'ghola');
|
|
const command: Action = { type: 'card', card: ghola.id, amount: 1, elite: 1 };
|
|
const before = structuredClone(g);
|
|
const frame = reload(observedEngine().applyActionInner(g, 'e', command));
|
|
assert.deepEqual(g, before);
|
|
assert.equal(
|
|
frame.pendingTreacheryDiscard?.continuation.kind,
|
|
'ordinaryCardDiscard',
|
|
);
|
|
const receipt = frame.pendingTreacheryDiscard!.continuation;
|
|
assert.equal(
|
|
receipt.kind === 'ordinaryCardDiscard' && receipt.effect,
|
|
'ghola',
|
|
);
|
|
assert.equal(emperor(frame).tanks, 0);
|
|
assert.equal(emperor(frame).elites!.tanks, 0);
|
|
assert.equal(emperor(frame).elites!.revived, 1);
|
|
assert.equal(frame.homeworlds!.custody!.salusa!.elite, 5);
|
|
assert.equal(frame.discard.filter((c) => c.id === ghola.id).length, 1);
|
|
const paused = structuredClone(frame);
|
|
for (const id of ['e', 'a']) {
|
|
const view = viewGame(reload(frame), id);
|
|
assert.equal(view.automaticContinuationPending, true);
|
|
assert.equal('pendingTreacheryDiscard' in view, false);
|
|
assert.equal(view.shipmentCompletion, null);
|
|
}
|
|
assert.deepEqual(frame, paused);
|
|
const done = normalizeAutomaticGame(reload(frame));
|
|
assert.equal(done.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(done.homeworlds, frame.homeworlds);
|
|
assert.deepEqual(done.players, frame.players);
|
|
assert.deepEqual(done.log, frame.log);
|
|
assert.deepEqual(done.deck, frame.deck);
|
|
assert.deepEqual(done.discard, frame.discard);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(done))), reload(done));
|
|
assert.deepEqual(reload(applyAction(g, 'e', command)), reload(done));
|
|
rejects(done, 'e', command);
|
|
const corrupt = reload(frame);
|
|
// A physically valid inter-world reassignment preserves every aggregate count
|
|
// but contradicts the already committed Ghola receipt.
|
|
corrupt.homeworlds!.custody!.salusa!.elite--;
|
|
homeworldGameIntegrity(corrupt);
|
|
assert.deepEqual(corrupt.players, frame.players);
|
|
const corruptBefore = structuredClone(corrupt);
|
|
assert.throws(() => viewGame(corrupt, 'e'));
|
|
assert.throws(() => normalizeAutomaticGame(corrupt));
|
|
rejects(corrupt, 'e', { type: 'ready' });
|
|
assert.deepEqual(corrupt, corruptBefore);
|
|
});
|
|
|
|
void test('shipment completion emits an executable mixed-Homeworld source witness without enabling Advanced Truthtrance promises', () => {
|
|
let g = fixture();
|
|
g = applyAction(g, 'e', action(g, 'homeworld:emperor', 14, 0));
|
|
g = movement(reload(g));
|
|
const promises: ShipmentPromise[] = [
|
|
{
|
|
turn: g.turn,
|
|
player: 'e',
|
|
asker: 'a',
|
|
territory: 'carthag',
|
|
minimum: 2,
|
|
answer: true,
|
|
},
|
|
];
|
|
const before = structuredClone(g);
|
|
const proof = observedEngine().findShipmentCompletion(
|
|
g,
|
|
emperor(g),
|
|
promises,
|
|
);
|
|
assert.ok(proof);
|
|
assert.deepEqual(
|
|
observedEngine().findShipmentCompletion(reload(g), emperor(g), promises),
|
|
proof,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
assert.equal(proof.actions.length, 1);
|
|
assert.deepEqual(proof.actions[0].homeworldSources, {
|
|
'homeworld:emperor': { normal: 1, elite: 0 },
|
|
'homeworld:emperor:salusa': { normal: 1, elite: 0 },
|
|
});
|
|
const moved = applyAction(g, 'e', proof.actions[0]);
|
|
assert.equal(emperor(moved).reserves, emperor(g).reserves - 2);
|
|
assert.equal(moved.homeworlds!.custody!.salusa!.normal, 13);
|
|
homeworldGameIntegrity(moved);
|
|
// The witness is private-helper readiness only. The real declaration keeps
|
|
// its deliberate Basic-only promise gate; no synthetic live promise is saved.
|
|
const card = hold(g, 'truthtrance', 'a');
|
|
g = applyAction(g, 'a', { type: 'card', card: card.id });
|
|
g = applyAction(g, 'e', { type: 'truthPass' });
|
|
rejects(
|
|
g,
|
|
'a',
|
|
{
|
|
type: 'truthAsk',
|
|
question: {
|
|
kind: 'shipment',
|
|
target: 'e',
|
|
territory: 'carthag',
|
|
minimum: 2,
|
|
},
|
|
},
|
|
);
|
|
});
|