380 lines
13 KiB
TypeScript
380 lines
13 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,
|
|
joinGame,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck, createAuditorLeader, CHOAM_AUDITOR_ID } from '../game/cards';
|
|
|
|
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
function fixture(tleilaxu = false) {
|
|
const g = createGame('AUDITORLIFE', newPlayer('c', 'CHOAM', 'choam'), true);
|
|
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
|
|
if (tleilaxu) g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 4,
|
|
turn: 2,
|
|
order: g.players.map((p) => p.id),
|
|
storm: 18,
|
|
deck: baseDeck(),
|
|
});
|
|
player(g, 'c').leaders.push(createAuditorLeader());
|
|
for (const p of g.players) {
|
|
p.spice = 20;
|
|
p.traitors = [];
|
|
}
|
|
const auditor = player(g, 'c').leaders.at(-1)!;
|
|
auditor.dead = true;
|
|
auditor.deaths = 1;
|
|
return g;
|
|
}
|
|
function hold(g: Game, id: string, effect: string) {
|
|
const index = g.deck.findIndex((c) => c.effect === effect);
|
|
assert.ok(index >= 0);
|
|
const card = g.deck.splice(index, 1)[0];
|
|
player(g, id).hand.push(card);
|
|
return card;
|
|
}
|
|
function allow(g: Game) {
|
|
for (
|
|
let n = 0;
|
|
n < 30 && (g.response || g.decision?.kind === 'revivalStop');
|
|
n++
|
|
) {
|
|
if (g.decision?.kind === 'revivalStop')
|
|
g = applyAction(g, g.decision.player, {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
else
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
}
|
|
assert.equal(g.response, null);
|
|
return g;
|
|
}
|
|
const revive = (g: Game) =>
|
|
applyAction(g, 'c', { type: 'reviveLeader', leader: CHOAM_AUDITOR_ID });
|
|
|
|
/** Expose the unmodified private initializer in an isolated test module only.
|
|
* Both real public entry points remain gated and are explicitly tested below. */
|
|
function setupModule() {
|
|
const source = readFileSync(
|
|
new URL('../game/engine.ts', import.meta.url),
|
|
'utf8',
|
|
);
|
|
const exports: { initializeSetup?: (g: Game) => void } = {};
|
|
runInNewContext(
|
|
ts.transpileModule(source + '\nexport { initializeSetup };\n', {
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
}).outputText,
|
|
{
|
|
exports,
|
|
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
|
|
crypto: webcrypto,
|
|
structuredClone,
|
|
TextEncoder,
|
|
JSON,
|
|
},
|
|
);
|
|
return exports.initializeSetup!;
|
|
}
|
|
|
|
void test('real isolated initializer adds the Advanced Auditor before prediction and traitor dealing', () => {
|
|
const initialize = setupModule();
|
|
for (const advanced of [false, true]) {
|
|
let g = createGame(
|
|
'AUDITORSETUP',
|
|
newPlayer('c', 'CHOAM', 'choam'),
|
|
advanced,
|
|
);
|
|
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
|
for (const id of ['c', 'b']) g = applyAction(g, id, { type: 'ready' });
|
|
initialize(g);
|
|
assert.equal(g.setupStage, 'prediction');
|
|
assert.equal(
|
|
player(g, 'c').leaders.filter((l) => l.id === CHOAM_AUDITOR_ID).length,
|
|
advanced ? 1 : 0,
|
|
);
|
|
assert.equal(player(g, 'c').leaders.length, advanced ? 6 : 5);
|
|
for (const p of g.players) {
|
|
assert.deepEqual(p.hand, []);
|
|
assert.deepEqual(p.traitorChoices, []);
|
|
assert.deepEqual(p.traitors, []);
|
|
assert.equal(p.spice, 0);
|
|
assert.deepEqual(p.forces, {});
|
|
}
|
|
g = applyAction(g, 'b', { type: 'predict', faction: 'choam', turn: 4 });
|
|
const pool = [
|
|
...(g.traitorReserve ?? []),
|
|
...g.players.flatMap((p) => [...p.traitors, ...p.traitorChoices]),
|
|
];
|
|
assert.equal(
|
|
pool.filter((id) => id === CHOAM_AUDITOR_ID).length,
|
|
advanced ? 1 : 0,
|
|
);
|
|
assert.equal(new Set(pool).size, advanced ? 11 : 10);
|
|
for (const id of ['c', 'b']) {
|
|
const p = player(g, id);
|
|
g = applyAction(g, id, { type: 'traitor', leader: p.traitorChoices[0] });
|
|
}
|
|
if (advanced)
|
|
g = applyAction(g, 'b', {
|
|
type: 'advisorSetup',
|
|
territory: 'polar_sink',
|
|
sector: 0,
|
|
});
|
|
assert.equal(g.status, 'playing');
|
|
assert.equal(g.phase, 0);
|
|
assert.equal(player(g, 'c').hand.length, 1);
|
|
assert.equal(
|
|
viewGame(g, 'b').players.find((p) => p.id === 'c')!.hand,
|
|
undefined,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('first Auditor revival works while five native leaders are alive and consumes the single native allowance', () => {
|
|
const start = fixture();
|
|
assert.equal(player(start, 'c').revivalCycle, 0);
|
|
assert.ok(
|
|
viewGame(start, 'c').revival.leaders.some(
|
|
(l) => l.id === CHOAM_AUDITOR_ID && l.cost === 2 && !l.early,
|
|
),
|
|
);
|
|
const g = allow(revive(JSON.parse(JSON.stringify(start))));
|
|
assert.equal(player(g, 'c').leaders.at(-1)!.dead, false);
|
|
assert.equal(player(g, 'c').leaders.at(-1)!.deaths, 1);
|
|
assert.equal(player(g, 'c').spice, 18);
|
|
assert.equal(player(g, 'c').leaderRevived, true);
|
|
player(g, 'c').leaders[0].dead = true;
|
|
player(g, 'c').leaders[0].deaths = 1;
|
|
player(g, 'c').revivalCycle = 1;
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() => applyAction(g, 'c', { type: 'reviveLeader', leader: 'choam-0' }),
|
|
/one leader/,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
const occupied = fixture();
|
|
player(occupied, 'c').leaderRevived = true;
|
|
assert.throws(() => revive(occupied), /one leader/);
|
|
});
|
|
|
|
void test('supplied each-turn Auditor return preserves repeated death history and the ordinary allowance', () => {
|
|
const start = fixture();
|
|
const auditor = player(start, 'c').leaders.at(-1)!;
|
|
auditor.deaths = 2;
|
|
const g = allow(revive(start));
|
|
assert.equal(player(g, 'c').leaders.at(-1)!.dead, false);
|
|
assert.equal(player(g, 'c').leaders.at(-1)!.deaths, 2);
|
|
assert.equal(player(g, 'c').revivalCycle, 0);
|
|
assert.equal(player(g, 'c').spice, 18);
|
|
assert.equal(player(g, 'c').leaderRevived, true);
|
|
assert.throws(() => revive(g), /one leader/);
|
|
});
|
|
|
|
void test('provisional five-disc CHOAM cycle ignores a living Auditor and its independent death history', () => {
|
|
const start = fixture();
|
|
const choam = player(start, 'c');
|
|
for (const leader of choam.leaders.slice(0, 5)) {
|
|
leader.dead = true;
|
|
leader.deaths = 1;
|
|
}
|
|
const auditor = choam.leaders.at(-1)!;
|
|
auditor.dead = false;
|
|
auditor.deaths = 3;
|
|
const g = allow(applyAction(start, 'c', { type: 'reviveLeader', leader: choam.leaders[0].id }));
|
|
assert.equal(player(g, 'c').revivalCycle, 1);
|
|
assert.equal(player(g, 'c').leaders[0].dead, false);
|
|
assert.equal(player(g, 'c').spice, 20 - choam.leaders[0].strength);
|
|
assert.deepEqual(player(g, 'c').leaders.at(-1), auditor);
|
|
});
|
|
|
|
void test('first Auditor revival uses the existing allied discount and pays the actual Tleilaxu revival income', () => {
|
|
let g = fixture(true);
|
|
player(g, 'c').ally = 't';
|
|
player(g, 't').ally = 'c';
|
|
g = applyAction(g, 't', { type: 'tleilaxuAllyDiscount' });
|
|
assert.equal(
|
|
viewGame(g, 'c').revival.leaders.find((l) => l.id === CHOAM_AUDITOR_ID)!
|
|
.cost,
|
|
1,
|
|
);
|
|
g = allow(revive(g));
|
|
assert.equal(player(g, 'c').spice, 19);
|
|
assert.equal(player(g, 't').spice, 21);
|
|
assert.equal(player(g, 'c').leaderRevived, true);
|
|
});
|
|
|
|
void test('Tleilaxu prevention cancels the first Auditor return without consuming payment or revival allowance', () => {
|
|
const start = fixture(true);
|
|
const karama = hold(start, 't', 'karama');
|
|
let g = revive(start);
|
|
assert.equal(g.decision?.kind, 'revivalStop');
|
|
g = applyAction(g, 't', { type: 'card', card: karama.id, mode: 'special' });
|
|
assert.equal(player(g, 'c').leaders.at(-1)!.dead, true);
|
|
assert.equal(player(g, 'c').spice, 20);
|
|
assert.equal(player(g, 'c').leaderRevived, false);
|
|
assert.ok(viewGame(g, 'c').revival.prevented);
|
|
assert.throws(() => revive(g), /prevented/);
|
|
});
|
|
|
|
void test('native Ghola card can restore the Auditor independently of the ordinary leader allowance', () => {
|
|
const start = fixture();
|
|
const ghola = hold(start, 'c', 'ghola');
|
|
player(start, 'c').leaderRevived = true;
|
|
const g = allow(
|
|
applyAction(start, 'c', {
|
|
type: 'card',
|
|
card: ghola.id,
|
|
leader: CHOAM_AUDITOR_ID,
|
|
}),
|
|
);
|
|
assert.equal(player(g, 'c').leaders.at(-1)!.dead, false);
|
|
assert.equal(player(g, 'c').leaders.at(-1)!.deaths, 1);
|
|
assert.equal(player(g, 'c').spice, 20);
|
|
assert.equal(player(g, 'c').leaderRevived, true);
|
|
assert.equal(g.discard.filter((c) => c.id === ghola.id).length, 1);
|
|
});
|
|
|
|
void test('foreign ghola rejects the canonical Auditor even when a Tleilaxu seat has room', () => {
|
|
const g = fixture(true);
|
|
player(g, 't').leaders[0].dead = true;
|
|
player(g, 't').leaders[0].deaths = 1;
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() =>
|
|
applyAction(g, 't', {
|
|
type: 'reviveForeignGhola',
|
|
leader: CHOAM_AUDITOR_ID,
|
|
}),
|
|
/Auditor/,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
|
|
void test('actual Harkonnen capture excludes the living canonical Auditor and still draws an eligible ordinary disc', () => {
|
|
for (const ordinaryAvailable of [false, true]) {
|
|
let g = fixture();
|
|
g.phase = 6;
|
|
g.active = 'h';
|
|
g.order = ['h', 'c'];
|
|
for (const p of g.players) {
|
|
p.forces = { 'arrakeen:10': 3 };
|
|
p.reserves = 17;
|
|
}
|
|
const choam = player(g, 'c');
|
|
for (const leader of choam.leaders) {
|
|
leader.dead = leader.id !== CHOAM_AUDITOR_ID;
|
|
leader.deaths = leader.dead ? 1 : 0;
|
|
}
|
|
if (ordinaryAvailable) {
|
|
choam.leaders[0].dead = false;
|
|
// Display names cannot turn an ordinary physical disc into the Auditor.
|
|
choam.leaders[0].name = 'Auditor';
|
|
}
|
|
g = applyAction(g, 'h', {
|
|
type: 'chooseBattle',
|
|
territory: 'arrakeen',
|
|
target: 'c',
|
|
});
|
|
for (let n = 0; n < 30; n++) {
|
|
g = allow(g);
|
|
if (g.battle?.preLeader && !g.battle.preLeader.closed) {
|
|
const id = ['h', 'c'].find(
|
|
(id) => !g.battle!.preLeader!.ready.includes(id),
|
|
)!;
|
|
g = applyAction(g, id, {
|
|
type: 'battlePreparationReady',
|
|
event: g.battle.event,
|
|
});
|
|
} else if (g.battle?.preparation)
|
|
g = applyAction(g, g.battle.preparation.owner, {
|
|
type: 'declineBattlePower',
|
|
});
|
|
else break;
|
|
}
|
|
g = applyAction(g, 'h', {
|
|
type: 'battlePlan',
|
|
leader: 'harkonnen-0',
|
|
dial: 0,
|
|
support: 0,
|
|
});
|
|
g = applyAction(g, 'c', {
|
|
type: 'battlePlan',
|
|
leader: CHOAM_AUDITOR_ID,
|
|
dial: 0,
|
|
support: 0,
|
|
});
|
|
g = applyAction(g, 'h', { type: 'traitorCall', call: false });
|
|
g = applyAction(g, 'c', { type: 'traitorCall', call: false });
|
|
if (ordinaryAvailable) {
|
|
assert.equal(g.decision?.kind, 'captureOffer');
|
|
g = allow(
|
|
applyAction(JSON.parse(JSON.stringify(g)), 'h', {
|
|
type: 'decision',
|
|
accept: true,
|
|
}),
|
|
);
|
|
assert.equal(g.decision?.kind, 'capturedLeader');
|
|
assert.equal(
|
|
g.decision?.kind === 'capturedLeader' && g.decision.leader,
|
|
'choam-0',
|
|
);
|
|
g = applyAction(g, 'h', { type: 'decision', mode: 'keep' });
|
|
assert.equal(player(g, 'c').leaders[0].capturedBy, 'h');
|
|
} else {
|
|
assert.notEqual(g.decision?.kind, 'captureOffer');
|
|
assert.equal(g.pendingCapture, null);
|
|
}
|
|
const auditor = player(g, 'c').leaders.find(
|
|
(l) => l.id === CHOAM_AUDITOR_ID,
|
|
)!;
|
|
assert.equal(auditor.dead, false);
|
|
assert.equal(auditor.capturedBy, undefined);
|
|
}
|
|
});
|
|
|
|
void test('canceling an allied Auditor discount restores the ordinary two-spice price before committing the return', () => {
|
|
let g = fixture(true);
|
|
player(g, 'c').ally = 't';
|
|
player(g, 't').ally = 'c';
|
|
const karama = hold(g, 'h', 'karama');
|
|
g = applyAction(g, 't', { type: 'tleilaxuAllyDiscount' });
|
|
g = revive(g);
|
|
assert.equal(g.decision?.kind, 'revivalStop');
|
|
g = applyAction(g, 't', { type: 'decision', decline: true });
|
|
assert.equal(g.response?.kind, 'revivalDiscount');
|
|
assert.equal(player(g, 'c').spice, 20);
|
|
assert.equal(player(g, 'c').leaders.at(-1)!.dead, true);
|
|
g = allow(
|
|
applyAction(JSON.parse(JSON.stringify(g)), 'h', {
|
|
type: 'card',
|
|
card: karama.id,
|
|
mode: 'cancel',
|
|
}),
|
|
);
|
|
assert.equal(player(g, 'c').spice, 18);
|
|
assert.equal(player(g, 't').spice, 22);
|
|
assert.equal(player(g, 'c').leaders.at(-1)!.dead, false);
|
|
assert.equal(player(g, 'c').leaderRevived, true);
|
|
assert.equal(g.discard.filter((c) => c.id === karama.id).length, 1);
|
|
});
|