Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,297 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user