Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
458 lines
16 KiB
TypeScript
458 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,
|
|
},
|
|
},
|
|
/Basic/,
|
|
);
|
|
});
|