Files
Dune/tests/box-discard-auditor.test.ts
2026-09-08 07:37:52 +10:00

298 lines
9.6 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 { baseDeck } from '../game/cards';
import { createAuditorLeader } from '../game/choam-auditor';
import type { FactionId } from '../game/catalog';
import { richeseCards } from '../game/richese-cards';
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;
nullentropyParentSignature?: (
g: Game,
resume: NonNullable<Game['pendingNullentropy']>['resume'],
searchEvent: string,
) => string;
} = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
'\nexport { applyActionInner, nullentropyParentSignature };\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 BOX = 'richese-nullentropy-box';
function fixture(faction: FactionId = 'richese', phase = 4) {
const g = createGame('BOXFRAME', newPlayer('p', 'Searcher', faction), true, [
'choam',
]);
g.players.push(
newPlayer('q', 'Guild', 'guild'),
newPlayer('e', 'Emperor', 'emperor'),
);
Object.assign(g, {
status: 'playing',
phase,
turn: 2,
storm: 18,
order: ['p', 'q', 'e'],
active: 'p',
deck: baseDeck(),
richeseCache: richeseCards(),
});
for (const p of g.players) {
p.spice = 10;
p.hand = [];
p.forces = {};
p.reserves = 20;
p.traitors = [];
}
hold(g, 'p', 'Nullentropy Box');
g.discard.push(...g.deck.splice(0, 4));
return g;
}
function hold(g: Game, id: string, name: string) {
const source = g.richeseCache!.some((c) => c.name === name)
? g.richeseCache!
: g.deck;
const index = source.findIndex((c) => c.name === name);
assert.ok(index >= 0, name);
const [card] = source.splice(index, 1);
player(g, id).hand.push(card);
return card.id;
}
function inventory(g: Game) {
const ids = [
...g.deck,
...g.discard,
...g.richeseCache!,
...g.players.flatMap((p) => p.hand),
]
.map((c) => c.id)
.sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
const begin = (g: Game, id = 'p') =>
applyAction(g, id, { type: 'card', card: BOX });
function selection(g: Game, card = g.discard[0].id): Action {
return { type: 'decision', event: g.pendingNullentropy!.event, card };
}
function completed(g = fixture()) {
const paid = begin(g),
action = selection(paid),
pending = inner(paid, 'p', action);
return { initial: g, paid, pending, action };
}
function receipt(g: Game) {
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind !== 'nullentropyDiscard') throw Error('Missing Box discard frame');
return c;
}
type AuditStage = 'payment' | 'response';
/** Explicit resolved-battle fixture, rather than an assertion that this test
* played a battle: CHOAM's surviving Auditor faced Richese at Arrakeen, used no
* opposing Treachery cards, and has reached the recorded response/payment stage.
* The Box activation, paid search, chosen transfer and discard frame are real. */
function auditorFixture(stage: AuditStage) {
const g = fixture('richese', 6);
const choam = newPlayer('q', 'CHOAM Auditor owner', 'choam');
choam.leaders.push(createAuditorLeader());
choam.leaders.find((l) => l.name === 'Auditor')!.usedAt = 'arrakeen';
choam.spice = 10;
g.players[1] = choam;
const event = `auditor-box-${stage}`;
g.lastBattle = ['q', 'p'];
g.lastBattleContext = {
event,
turn: g.turn,
territory: 'arrakeen',
combatants: ['q', 'p'],
winner: 'q',
result: 'normal',
};
g.pendingAuditor = {
event,
owner: 'q',
opponent: 'p',
territory: 'arrakeen',
turn: g.turn,
survived: true,
usedCards: [],
stage,
};
if (stage === 'payment')
g.decision = { kind: 'choamAuditPayment', player: 'p', event };
else {
g.response = { kind: 'choamAudit', owner: 'q', intent: event, passed: [] };
// Keep the actual cancellation window open after automatic Box recovery.
hold(g, 'e', 'Karama');
}
return g;
}
/** Rebuild the receipt with the production serializer so these cases exercise
* authoritative Auditor semantics, rather than only stale-signature rejection. */
function signParent(g: Game) {
const c = receipt(g);
c.parentSignature = observed.nullentropyParentSignature!(
g,
c.resume,
c.searchEvent,
);
}
function rejectedParent(g: Game) {
signParent(g);
const before = structuredClone(g);
for (const seat of g.players)
assert.throws(
() => viewGame(g, seat.id),
/Auditor|battle|stale|continuation/i,
);
assert.throws(
() => normalizeAutomaticGame(g),
/Auditor|battle|stale|continuation/i,
);
assert.throws(
() => applyAction(g, 'p', { type: 'advanceBots' }),
/Auditor|battle|stale|continuation/i,
);
assert.deepEqual(g, before);
}
for (const stage of ['payment', 'response'] as const) {
void test(`Box ${stage} parent rejects semantically invalid Auditor controls even with a recomputed receipt`, () => {
const initial = auditorFixture(stage);
const { pending } = completed(initial);
const ids = inventory(pending);
// Establish that the explicitly constructed parent is valid and visible
// without disclosing the recovered card to the Auditor or observer.
const selected = receipt(pending).selected.id;
for (const seat of pending.players) {
const view = viewGame(pending, seat.id);
assert.equal(view.auditor?.event, initial.pendingAuditor!.event);
assert.equal(view.auditorInsight, null);
assert.equal(Object.hasOwn(view, 'pendingTreacheryDiscard'), false);
if (seat.id !== 'p')
assert.equal(JSON.stringify(view).includes(selected), false);
}
const done = normalizeAutomaticGame(reload(pending));
assert.deepEqual(done.decision, initial.decision);
assert.deepEqual(done.response, initial.response);
assert.deepEqual(done.pendingAuditor, initial.pendingAuditor);
assert.deepEqual(done.discard, pending.discard);
assert.deepEqual(inventory(done), ids);
assert.equal(player(done, 'p').spice, 8);
assert.equal(player(done, 'q').spice, 10);
const changes: ((g: Game) => void)[] = [
(g) => {
g.pendingAuditor!.turn--;
},
(g) => {
g.lastBattle = ['q', 'e'];
},
(g) => {
player(g, 'q').faction = 'guild';
},
(g) => {
g.pendingAuditor!.stage = 'offer';
},
(g) => {
const resume = receipt(g).resume;
if (resume.decision?.kind === 'choamAuditPayment')
resume.decision.event = 'other-payment';
else if (resume.response) resume.response.intent = 'other-response';
},
(g) => {
const resume = receipt(g).resume;
if (resume.decision?.kind === 'choamAuditPayment')
resume.decision.player = 'q';
else if (resume.response) resume.response.owner = 'p';
},
];
for (const change of changes) {
const bad = reload(pending);
change(bad);
rejectedParent(bad);
}
});
}
void test('Box Auditor continuation remains bound to its recorded battle even after coordinated receipt edits', () => {
for (const stage of ['payment', 'response'] as const) {
const { pending } = completed(auditorFixture(stage));
for (const change of [
(g: Game) => {
g.pendingAuditor!.event = 'forged-audit-event';
const resume = receipt(g).resume;
if (resume.decision?.kind === 'choamAuditPayment')
resume.decision.event = 'forged-audit-event';
else if (resume.response) resume.response.intent = 'forged-audit-event';
},
(g: Game) => {
g.pendingAuditor!.territory = 'carthag';
},
(g: Game) => {
g.lastBattleContext!.turn--;
},
(g: Game) => {
g.lastBattleContext!.combatants.reverse();
},
]) {
const bad = reload(pending);
change(bad);
rejectedParent(bad);
}
}
});
void test('legacy Auditor parents without a battle-context receipt still restore through Box', () => {
for (const stage of ['payment', 'response'] as const) {
const initial = auditorFixture(stage);
delete initial.lastBattleContext;
const { pending } = completed(initial);
const done = normalizeAutomaticGame(reload(pending));
assert.equal(done.lastBattleContext, undefined);
assert.deepEqual(done.pendingAuditor, initial.pendingAuditor);
assert.deepEqual(done.decision, initial.decision);
assert.deepEqual(done.response, initial.response);
assert.equal(player(done, 'p').spice, 8);
assert.equal(player(done, 'q').spice, 10);
}
});