Files
Dune/tests/revival-resume.test.ts
2026-09-08 07:37:52 +10:00

625 lines
19 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 {
createGame,
newPlayer,
joinGame,
applyAction,
RuleError,
type Game,
type Action,
} from '../game/engine';
import { baseDeck, createAuditorLeader } from '../game/cards';
import { newRevivalRules } from '../game/revival';
import { createTechTokens } from '../game/tech-tokens';
import { quoteRevivalResume, RevivalResumeError } from '../game/revival-resume';
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const json = <T>(value: T): T => JSON.parse(JSON.stringify(value));
/** Observe genuine declarations immediately before beginRevival's ordinary
* CHOAM/stop dispatch. The production module and public start gates are unchanged. */
const engineURL = new URL('../game/engine.ts', import.meta.url);
const source = readFileSync(engineURL, 'utf8');
const entry = 'function beginRevival(g: Game, revival: PendingRevival) {';
assert.equal(source.split(entry).length, 2);
const observed: {
applyAction: typeof applyAction;
RuleError: typeof RuleError;
captures: Game[];
finishRevival: (g: Game) => void;
offerRevivalStop: (g: Game) => void;
} = {} as never;
let randomCalls = 0;
runInNewContext(
ts.transpileModule(
'const captures: Game[] = [];\n' +
source.replace(
entry,
entry +
'\ncaptures.push(structuredClone({...g, pendingRevival: revival}));',
) +
'\nexport { captures, finishRevival, offerRevivalStop };',
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports: observed,
require: createRequire(engineURL),
structuredClone,
TextEncoder,
JSON,
crypto: {
...webcrypto,
randomUUID: () => {
randomCalls++;
return webcrypto.randomUUID();
},
},
Math: Object.assign(Object.create(Math), {
random: () => {
randomCalls++;
return 0.25;
},
}),
},
);
function fixture(advanced = true) {
const g = createGame(
'REVIVALRESUME',
newPlayer('c', 'CHOAM', 'choam'),
advanced,
['choam', 'ix'],
);
for (const [id, faction] of [
['e', 'emperor'],
['t', 'tleilaxu'],
['a', 'atreides'],
['b', 'beneGesserit'],
['i', 'ixians'],
] as const)
joinGame(g, newPlayer(id, faction, faction));
Object.assign(g, {
status: 'playing',
phase: 4,
turn: 2,
order: g.players.map((p) => p.id),
deck: baseDeck(),
revivalRules: newRevivalRules(),
});
for (const p of g.players)
Object.assign(p, {
hand: [],
spice: 40,
tanks: 10,
reserves: 10,
forces: {},
});
for (const [id, kind] of [
['e', 'karama'],
['b', 'worthless'],
] as const) {
const index = g.deck.findIndex((card) =>
kind === 'karama' ? card.effect === kind : card.kind === kind,
);
player(g, id).hand.push(g.deck.splice(index, 1)[0]);
}
return g;
}
const act = (g: Game, id: string, action: Action) =>
observed.applyAction(g, id, action);
function allow(g: Game) {
for (let n = 0; n < 40 && (g.decision || g.response); n++) {
if (g.decision) {
assert.ok(['choamFreeRevival', 'revivalStop'].includes(g.decision.kind));
g = act(g, g.decision.player, { type: 'decision', decline: true });
} else {
const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id;
g = act(g, id, { type: 'passResponse' });
}
}
assert.equal(g.pendingRevival, null);
return g;
}
type Case =
| 'forces'
| 'limitDiscount'
| 'choam'
| 'ix'
| 'early'
| 'foreign'
| 'native'
| 'kwisatz'
| 'negotiated'
| 'buyback'
| 'auditor'
| 'extra';
function declaration(kind: Case, advanced = true) {
let g = fixture(advanced);
observed.captures.length = 0;
if (kind === 'limitDiscount' || kind === 'ix') {
const id = kind === 'ix' ? 'i' : 'a';
player(g, 't').ally = id;
player(g, id).ally = 't';
g = act(g, 't', { type: 'tleilaxuAllyDiscount' });
if (kind === 'limitDiscount')
g = act(g, 't', { type: 'tleilaxuRevivalLimit', target: id });
else
player(g, id).elites = { tanks: 2, reserves: 2, forces: {}, revived: 0 };
g = act(g, id, {
type: 'revive',
amount: kind === 'ix' ? 2 : 5,
...(kind === 'ix' ? { elite: 1, freeElite: 0 } : {}),
});
} else if (kind === 'forces' || kind === 'choam') {
g = act(g, kind === 'choam' ? 'c' : 'e', { type: 'revive', amount: 3 });
} else if (kind === 'extra') {
player(g, 'e').ally = 'a';
player(g, 'a').ally = 'e';
g = act(g, 'e', { type: 'emperorRevival', amount: 2 });
while (!observed.captures.length) {
assert.equal(g.response?.kind, 'emperorRevival');
g = act(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
}
} else if (kind === 'early' || kind === 'foreign') {
player(g, 't').leaders[0].dead = true;
player(g, 't').leaders[0].deaths = 1;
player(g, 'a').leaders[0].dead = true;
player(g, 'a').leaders[0].deaths = 1;
g = act(g, 't', {
type: kind === 'early' ? 'reviveLeader' : 'reviveForeignGhola',
leader: player(g, kind === 'early' ? 't' : 'a').leaders[0].id,
});
} else if (kind === 'negotiated' || kind === 'buyback') {
const leader = player(g, 'a').leaders[0];
leader.dead = true;
leader.deaths = 1;
if (kind === 'buyback') leader.gholaBy = 't';
g = act(g, 'a', { type: 'requestLeaderRevival', leader: leader.id });
g = act(g, 't', { type: 'quoteLeaderRevival', target: 'a', amount: 17 });
g = act(g, 'a', { type: 'acceptLeaderRevival' });
} else if (kind === 'auditor') {
const leader = createAuditorLeader();
leader.dead = true;
leader.deaths = 1;
player(g, 'c').leaders.push(leader);
g = act(g, 'c', { type: 'reviveLeader', leader: leader.id });
} else {
player(g, 'a').leaders.forEach((l) => {
l.dead = true;
l.deaths = 1;
});
if (kind === 'kwisatz')
player(g, 'a').kwisatz = { dead: true, revivalCycle: 1 };
g = act(g, 'a', {
type: kind === 'kwisatz' ? 'reviveKwisatz' : 'reviveLeader',
...(kind === 'native' ? { leader: player(g, 'a').leaders[0].id } : {}),
});
}
assert.equal(observed.captures.length, 1);
return { g, captured: json(observed.captures[0]) };
}
function finishState(captured: Game) {
const state = json(captured);
state.decision = null;
state.response = null;
return state;
}
void test('unchanged mixed free/paid revival reaches the real stop decision before any queued or paid work', () => {
const { g, captured } = declaration('forces');
const before = json(captured),
rng = randomCalls;
const quote = quoteRevivalResume(captured, 'stop');
assert.equal(quote.outcome, 'decision');
assert.deepEqual(quote.nextDecision, {
kind: 'revivalStop',
player: 't',
recipient: 'e',
revival: 'forces',
});
assert.deepEqual(quote.pending, {
player: 'e',
kind: 'forces',
amount: 3,
elite: 0,
free: 1,
cost: 4,
normalCost: 4,
checks: [],
});
assert.equal(quote.nextResponse, undefined);
assert.equal(randomCalls, rng);
assert.deepEqual(captured, before);
quote.pending.checks.push('revivalDiscount');
assert.deepEqual(captured, before);
const done = allow(g);
assert.equal(player(done, 'e').tanks, 7);
assert.equal(player(done, 'e').reserves, 13);
assert.equal(player(done, 'e').spice, 36);
assert.equal(player(done, 't').spice, 45);
assert.equal(done.revivalFreeIncome?.e, 2);
});
void test('initial ordered limit then discount queue remains frozen, including its agreed half price', () => {
const { g, captured } = declaration('limitDiscount');
assert.deepEqual(captured.pendingRevival?.checks, [
'revivalLimit',
'revivalDiscount',
]);
assert.equal(quoteRevivalResume(captured, 'stop').outcome, 'decision');
const quote = quoteRevivalResume(captured, 'finish');
assert.equal(quote.outcome, 'response');
assert.deepEqual(quote.nextResponse, {
kind: 'revivalLimit',
owner: 't',
recipient: 'a',
passed: [],
});
assert.deepEqual(quote.pending, captured.pendingRevival);
const after = allow(g);
assert.equal(player(after, 'a').revived, 5);
assert.equal(player(after, 'a').spice, 40 - captured.pendingRevival!.cost);
});
void test('Basic skips the Advanced stop while finish explicitly bypasses it without changing a hand-dependent offer', () => {
const { captured } = declaration('forces', false);
const basic = quoteRevivalResume(captured, 'stop');
assert.equal(basic.outcome, 'revive');
captured.advanced = true;
const choice = quoteRevivalResume(captured, 'stop');
assert.equal(choice.outcome, 'decision');
for (const p of captured.players) p.hand = [];
assert.deepEqual(quoteRevivalResume(captured, 'stop'), choice);
assert.equal(quoteRevivalResume(captured, 'finish').outcome, 'revive');
player(captured, 't').specialKaramaUsed = true;
assert.equal(quoteRevivalResume(captured, 'stop').outcome, 'revive');
});
void test('all real leader, foreign, negotiated, Auditor, and Emperor-extra producers keep their source and frozen price', () => {
for (const kind of [
'early',
'foreign',
'native',
'kwisatz',
'negotiated',
'buyback',
'auditor',
'extra',
] as const) {
const { g, captured } = declaration(kind);
const before = json(captured),
quote = quoteRevivalResume(captured, 'finish');
assert.deepEqual(quote.pending, captured.pendingRevival, kind);
assert.deepEqual(captured, before, kind);
const after = allow(g),
pending = captured.pendingRevival!;
if (kind === 'extra') {
assert.equal(after.emperorExtra.a, 2);
assert.equal(player(after, 'a').revived, 0);
assert.equal(player(after, 'e').spice, 36);
assert.equal(player(after, 't').spice, 44);
assert.equal(quote.nextResponse?.recipient, 'e');
} else if (kind === 'kwisatz') {
assert.equal(player(after, 'a').kwisatz?.dead, false);
assert.equal(player(after, 'a').kwisatz?.revivalCycle, 2);
} else {
const leader = after.players
.flatMap((p) => p.leaders)
.find((l) => l.id === pending.leader)!;
assert.equal(leader.dead, false, kind);
assert.equal(leader.gholaBy, kind === 'foreign' ? 't' : undefined, kind);
}
}
});
void test('Ix free cyborg allocation remains the actual selected frozen quote', () => {
const { g, captured } = declaration('ix');
const pending = captured.pendingRevival!;
assert.equal(pending.free, 1);
assert.equal(pending.normalCost, 3);
assert.equal(pending.cost, 2);
assert.deepEqual(quoteRevivalResume(captured, 'finish').pending, pending);
const after = allow(g);
assert.equal(player(after, 'i').elites?.tanks, 1);
assert.equal(player(after, 'i').elites?.reserves, 3);
assert.equal(player(after, 'i').spice, 38);
});
void test('unfunded and Fremen allowance abandonment precede physical custody and never produce income', () => {
const { captured } = declaration('forces');
player(captured, 'e').spice = 0;
player(captured, 'e').tanks = 0;
const q = quoteRevivalResume(captured, 'finish');
assert.equal(q.outcome, 'unfunded');
assert.equal(q.nextResponse, undefined);
const real = finishState(captured);
observed.finishRevival(real);
assert.equal(real.pendingRevival, null);
assert.equal(player(real, 'e').tanks, 0);
const fremen = json(captured);
player(fremen, 'e').faction = 'fremen';
fremen.players = fremen.players.filter((p) => p.id !== 't');
player(fremen, 'e').tanks = 10;
player(fremen, 'e').revived = 3;
assert.equal(quoteRevivalResume(fremen, 'finish').outcome, 'fremenLimit');
const actual = finishState(fremen);
observed.finishRevival(actual);
assert.equal(actual.pendingRevival, null);
assert.equal(player(actual, 'e').tanks, 10);
});
void test('safe exact Axlotl income and once-per-turn free-income receipts are quoted only at actual commit', () => {
const { captured } = declaration('forces');
captured.techTokens = createTechTokens(captured.players);
captured.techTokens.heighliners.owner = 't';
const q = quoteRevivalResume(captured, 'finish');
assert.deepEqual(q.techIncome, {
id: 'axlotl',
owner: 't',
spice: 2,
triggeredTurn: 2,
});
assert.deepEqual(q.freeIncome, { player: 'e', turn: 2 });
assert.deepEqual(q.nextResponse, {
kind: 'revivalIncome',
owner: 't',
recipient: 'e',
amount: 5,
passed: [],
});
const real = finishState(captured);
observed.finishRevival(real);
assert.equal(real.techTokens!.axlotl.spice, 2);
assert.equal(real.techTokens!.axlotl.triggeredTurn, 2);
assert.deepEqual(json(real.response), q.nextResponse);
assert.equal(quoteRevivalResume(captured, 'stop').techIncome, undefined);
captured.revivalFreeIncome = { e: 2 };
captured.techTokens.axlotl.triggeredTurn = 2;
const repeated = quoteRevivalResume(captured, 'finish');
assert.equal(repeated.techIncome, undefined);
assert.equal(repeated.nextResponse?.amount, 4);
});
void test('corrupted source, ordered checks, physical custody and counter overflow reject without mutation', () => {
const { captured } = declaration('forces');
const cases: [string, (g: Game) => void][] = [
[
'phase',
(g) => {
g.phase = 5;
},
],
[
'turn',
(g) => {
g.turn = NaN;
},
],
[
'missing request',
(g) => {
g.pendingRevival = null;
},
],
[
'target',
(g) => {
g.pendingRevival!.player = 'absent';
},
],
[
'checks',
(g) => {
g.pendingRevival!.checks = null as never;
},
],
[
'reversed checks',
(g) => {
g.pendingRevival!.checks = ['revivalDiscount', 'revivalLimit'];
},
],
[
'duplicate checks',
(g) => {
g.pendingRevival!.checks = ['revivalDiscount', 'revivalDiscount'];
},
],
[
'wrong check kind',
(g) => {
g.pendingRevival!.checks = ['foreignGhola'];
},
],
[
'missing check owner',
(g) => {
g.players = g.players.filter((p) => p.id !== 't');
g.pendingRevival!.checks = ['revivalDiscount'];
},
],
[
'amount',
(g) => {
g.pendingRevival!.amount = 99;
},
],
[
'within-tanks amount changed without repricing',
(g) => {
g.pendingRevival!.amount = 4;
},
],
[
'negative cost',
(g) => {
g.pendingRevival!.cost = -1;
},
],
[
'unsupported force rate',
(g) => {
g.pendingRevival!.cost = 0;
},
],
[
'wrong payer',
(g) => {
g.pendingRevival!.payer = 'c';
},
],
[
'missing typed tanks',
(g) => {
g.pendingRevival!.elite = 1;
},
],
[
'elite custody',
(g) => {
player(g, 'e').elites = {
tanks: 9,
reserves: 0,
forces: {},
revived: 0,
};
},
],
[
'reserve overflow',
(g) => {
player(g, 'e').reserves = Number.MAX_SAFE_INTEGER;
},
],
[
'quota overflow',
(g) => {
player(g, 'e').revived = Number.MAX_SAFE_INTEGER;
},
],
[
'free counter',
(g) => {
player(g, 'e').freeForcesRevived = -1;
},
],
];
for (const [label, mutate] of cases) {
const bad = json(captured);
mutate(bad);
const before = structuredClone(bad);
assert.throws(
() => quoteRevivalResume(bad, 'finish'),
RevivalResumeError,
label,
);
assert.deepEqual(bad, before, label);
}
});
void test('Axlotl and immediate income corruption rejects but genuine earlier decision remains a boundary', () => {
const { captured } = declaration('forces');
captured.techTokens = createTechTokens(captured.players);
for (const mutate of [
(g: Game) => {
g.techTokens!.axlotl.owner = 'absent';
},
(g: Game) => {
g.techTokens!.heighliners = null as never;
},
(g: Game) => {
g.techTokens!.axlotl.spice = -1;
},
(g: Game) => {
g.revivalFreeIncome = { e: -1 };
},
]) {
const bad = json(captured);
mutate(bad);
const before = structuredClone(bad);
assert.equal(quoteRevivalResume(bad, 'stop').outcome, 'decision');
assert.throws(() => quoteRevivalResume(bad, 'finish'), RevivalResumeError);
assert.deepEqual(bad, before);
}
});
void test('legacy unknown free usage remains unknown; impossible leader/Emperor custody rejects', () => {
const { captured } = declaration('forces');
player(captured, 'e').revived = 1;
delete player(captured, 'e').freeForcesRevived;
assert.equal(quoteRevivalResume(captured, 'finish').outcome, 'revive');
const state = finishState(captured);
observed.finishRevival(state);
assert.equal(player(state, 'e').freeForcesRevived, undefined);
for (const kind of ['native', 'kwisatz', 'foreign', 'extra'] as const) {
const { captured: bad } = declaration(kind);
if (kind === 'native') player(bad, 'a').leaders[0].capturedBy = 'e';
if (kind === 'kwisatz') player(bad, 'a').kwisatz!.dead = false;
if (kind === 'foreign') player(bad, 't').leaders[0].dead = false;
if (kind === 'extra') bad.emperorExtra.a = Number.MAX_SAFE_INTEGER;
assert.throws(
() => quoteRevivalResume(bad, 'finish'),
RevivalResumeError,
kind,
);
}
});
void test('actual printed and BG cancellation of reactive La La La reject corrupted unchanged revival before cost', () => {
for (const form of ['printed', 'converted'] as const) {
let g = fixture();
const i = g.deck.findIndex((c) => c.name === 'La La La');
assert.ok(i >= 0);
const card = g.deck.splice(i, 1)[0];
player(g, 'c').hand.push(card);
g = act(g, 'e', { type: 'revive', amount: 3 });
assert.equal(g.decision?.kind, 'choamFreeRevival');
g = act(g, 'c', { type: 'card', card: card.id, mode: 'choam' });
assert.equal(g.response?.kind, 'choamWorthless');
const actor = form === 'printed' ? 'e' : 'b';
const cost = player(g, actor).hand.find((c) =>
form === 'printed' ? c.effect === 'karama' : c.kind === 'worthless',
)!;
for (const mutate of [
(bad: Game) => {
bad.pendingRevival!.cost = 0;
},
(bad: Game) => {
bad.pendingRevival!.amount = 4;
},
(bad: Game) => {
bad.pendingRevival!.checks = null as never;
},
(bad: Game) => {
player(bad, 'e').elites = {
tanks: 9,
reserves: 0,
forces: {},
revived: 0,
};
},
]) {
const bad = json(g);
mutate(bad);
const before = structuredClone(bad),
rng = randomCalls;
assert.throws(
() => act(bad, actor, { type: 'card', mode: 'cancel', card: cost.id }),
observed.RuleError,
);
assert.deepEqual(bad, before);
assert.equal(randomCalls, rng);
}
const paid = act(g, actor, { type: 'card', mode: 'cancel', card: cost.id });
const done = allow(paid);
assert.equal(done.discard.filter((c) => c.id === cost.id).length, 1);
assert.equal(player(done, 'e').revived, 3);
assert.equal(player(done, 'e').spice, 36);
assert.equal(player(done, 't').spice, 45);
}
});