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

714 lines
24 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 { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { baseDeck } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { richeseCards } from '../game/richese-cards';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { createAuditorLeader, CHOAM_AUDITOR_ID } from '../game/choam-auditor';
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;
finishResponse?: (g: Game, canceled: boolean) => void;
} = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
'\nexport { applyActionInner, finishResponse };\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;
}
function recover(g: Game) {
const before = structuredClone(g),
done = normalizeAutomaticGame(reload(g));
assert.deepEqual(g, before);
assert.equal(done.pendingTreacheryDiscard, null);
// JSON omits absent optional controls; compare their persisted representation.
assert.deepEqual(reload(normalizeAutomaticGame(reload(done))), reload(done));
return done;
}
void test('paid many-choice Box commits only its used Box and resumes without another fee, selection or shuffle', (t) => {
const { initial, paid, pending } = completed(),
c = receipt(pending),
batch = pending.pendingTreacheryDiscard!.batch;
assert.equal(paid.pendingNullentropy!.event, c.searchEvent);
assert.equal(batch.cause, 'nullentropyBox');
assert.equal(batch.entries.length, 1);
assert.deepEqual(
batch.entries.map((e) => [e.card.id, e.discardedBy, e.publicFace]),
[[BOX, 'p', true]],
);
assert.equal(pending.pendingNullentropy, null);
assert.equal(pending.decision, null);
assert.equal(player(pending, 'p').spice, 8);
assert.equal(player(pending, 'q').spice, 10);
assert.deepEqual(player(pending, 'p').hand, [c.selected]);
assert.equal(pending.discard.at(-1)!.id, BOX);
assert.deepEqual(
c.finalDiscardIds,
pending.discard.map((c) => c.id),
);
assert.equal(c.finalDiscardSignature, JSON.stringify(pending.discard));
assert.deepEqual(
pending.discard
.slice(0, -1)
.map((c) => c.id)
.sort(),
initial.discard
.filter((x) => x.id !== c.selected.id)
.map((x) => x.id)
.sort(),
);
t.mock.method(crypto, 'getRandomValues', () => {
throw Error('Recovery must not reshuffle');
});
const done = recover(pending);
assert.deepEqual(done.players, pending.players);
assert.deepEqual(done.discard, pending.discard);
assert.deepEqual(done.log, pending.log);
assert.deepEqual(done.deck, pending.deck);
assert.equal(done.resolvedTreacheryDiscardSequence, 1);
assert.deepEqual(inventory(done), inventory(initial));
const replay = reload(done);
replay.pendingTreacheryDiscard = pending.pendingTreacheryDiscard;
assert.throws(() => normalizeAutomaticGame(replay));
});
void test('sole-choice paid Box emits and drains the same frame within one ordinary card action', () => {
const initial = fixture();
initial.deck.push(...initial.discard.splice(1));
const selected = structuredClone(initial.discard[0]);
const pending = inner(initial, 'p', { type: 'card', card: BOX });
assert.equal(receipt(pending).selected.id, selected.id);
assert.equal(pending.pendingNullentropy, null);
assert.equal(player(pending, 'p').spice, 8);
const done = recover(pending),
ordinary = begin(initial);
assert.deepEqual(done.players, ordinary.players);
assert.deepEqual(done.discard, ordinary.discard);
assert.deepEqual(done.log, ordinary.log);
assert.equal(ordinary.pendingTreacheryDiscard, null);
assert.equal(ordinary.decision, null);
assert.equal(ordinary.resolvedTreacheryDiscardSequence, 1);
assert.deepEqual(inventory(done), inventory(initial));
});
void test('Box frames expose the used Box but never the privately recovered identity and all profiles wait', () => {
const { pending } = completed(),
alternate = reload(pending),
c = receipt(alternate);
const recovered = player(alternate, 'p').hand.findIndex(
(x) => x.id === c.selected.id,
);
const substitute = alternate.deck[0];
alternate.deck[0] = player(alternate, 'p').hand[recovered];
player(alternate, 'p').hand[recovered] = substitute;
c.selected = structuredClone(substitute);
for (const p of pending.players) {
const view = viewGame(pending, p.id);
assert.equal(view.automaticContinuationPending, true);
assert.equal(view.nullentropy, null);
assert.ok(!('pendingTreacheryDiscard' in view));
if (p.id !== 'p') {
assert.ok(!JSON.stringify(view).includes(receipt(pending).selected.id));
assert.deepEqual(viewGame(alternate, p.id), view);
}
for (const level of DIFFICULTIES) {
view.players.find((x) => x.id === p.id)!.bot = level;
assert.deepEqual(botActions(view), []);
}
}
const done = runBots(reload(pending), 0);
assert.deepEqual(
{ ...done, botsPending: undefined },
{ ...recover(pending), botsPending: undefined },
);
});
void test('Box restores an actual paid shipment income response without repeating transport or income', () => {
let initial = fixture('richese', 5);
initial.movementRemaining = [...initial.order];
hold(initial, 'e', 'Karama');
initial = applyAction(initial, 'p', {
type: 'ship',
amount: 1,
territory: 'arrakeen',
sector: 10,
});
assert.equal(initial.decision?.kind, 'guildShipment');
initial = applyAction(initial, 'q', { type: 'decision', allow: true });
assert.equal(initial.response?.kind, 'guildIncome');
const { pending } = completed(initial),
parent = structuredClone(initial.response),
done = recover(pending);
assert.equal(pending.response, null);
assert.deepEqual(receipt(pending).resume.response, parent);
assert.deepEqual(done.response, parent);
assert.deepEqual(done.players, pending.players);
assert.equal(player(done, 'p').spice, 7);
assert.equal(player(done, 'p').reserves, 19);
assert.equal(player(done, 'q').spice, 10);
let result = done;
while (result.response)
result = applyAction(
result,
result.players.find((p) => !result.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
assert.equal(player(result, 'q').spice, 11);
assert.equal(player(result, 'p').reserves, 19);
assert.deepEqual(inventory(result), inventory(initial));
});
void test('Box restores a suspended phase opening without initializing or replaying passes', () => {
const initial = fixture('richese', 5);
initial.phaseOpening = { passed: ['p'], initialize: false };
const { pending } = completed(initial);
assert.equal(pending.phaseOpening, null);
const done = recover(pending);
assert.deepEqual(done.phaseOpening, initial.phaseOpening);
assert.deepEqual(done.players, pending.players);
assert.deepEqual(done.log, pending.log);
});
void test('unreserved Box recovery preserves an actual Richese gift response and its separate card', () => {
let initial = fixture();
player(initial, 'p').ally = 'q';
player(initial, 'q').ally = 'p';
const gift = hold(initial, 'p', 'Distrans');
hold(initial, 'e', 'Karama');
initial = applyAction(initial, 'p', { type: 'richeseGift', card: gift });
assert.equal(initial.response?.kind, 'richeseGift');
const { pending } = completed(initial),
parent = reload(initial).pendingRicheseGift;
const done = recover(pending);
assert.deepEqual(done.pendingRicheseGift, parent);
assert.deepEqual(done.response, initial.response);
assert.ok(player(done, 'p').hand.some((c) => c.id === gift));
let result = done;
while (result.response)
result = applyAction(
result,
result.players.find((p) => !result.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
assert.ok(player(result, 'q').hand.some((c) => c.id === gift));
assert.ok(!player(result, 'p').hand.some((c) => c.id === gift));
assert.equal(result.pendingRicheseGift, null);
assert.deepEqual(inventory(result), inventory(initial));
});
void test('Box suspends a genuine BG Worthless conversion without treating its previous discard as fresh', () => {
let initial = fixture('beneGesserit', 5);
initial.active = 'q';
const worthless = initial.deck.find((c) => c.kind === 'worthless')!;
initial.deck.splice(
initial.deck.findIndex((c) => c.id === worthless.id),
1,
);
player(initial, 'p').hand.push(worthless);
hold(initial, 'e', 'Karama');
initial = applyAction(initial, 'p', {
type: 'card',
card: worthless.id,
mode: 'shipment',
target: 'q',
});
assert.equal(initial.response?.kind, 'worthlessKarama');
assert.ok(initial.pendingKarama);
const { pending } = completed(initial),
done = recover(pending);
assert.deepEqual(done.pendingKarama, initial.pendingKarama);
assert.deepEqual(done.response, initial.response);
assert.deepEqual(
pending.pendingTreacheryDiscard!.batch.entries.map((e) => e.card.id),
[BOX],
);
assert.ok(pending.discard.some((c) => c.id === worthless.id));
let result = done;
while (result.response)
result = applyAction(
result,
result.players.find((p) => !result.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
assert.deepEqual(result.karamaShipping, {
owner: 'p',
player: 'q',
card: worthless.id,
});
assert.equal(result.pendingKarama, null);
assert.deepEqual(inventory(result), inventory(initial));
});
void test('a restored Harkonnen bonus may refill after Box retirement without repeating the Box transaction', () => {
const initial = fixture('richese', 3);
player(initial, 'q').faction = 'harkonnen';
const purchased = initial.deck.shift()!;
player(initial, 'q').hand.push(purchased);
initial.auction = {
cards: [purchased],
index: 0,
bid: 2,
bidder: 'q',
active: 'q',
passed: [],
opener: 0,
};
initial.currentAuctionSale = {
winner: 'q',
amount: 2,
free: false,
origin: 'normal',
seller: null,
};
initial.response = { kind: 'harkonnenBonus', owner: 'q', passed: [] };
initial.discard.push(...initial.deck);
initial.deck = [];
const { pending } = completed(initial);
assert.equal(player(pending, 'q').hand.length, 1);
assert.equal(pending.deck.length, 0);
assert.equal(pending.discard.at(-1)!.id, BOX);
const done = recover(pending);
assert.equal(player(done, 'q').hand.length, 2);
assert.equal(player(done, 'p').hand.length, 1);
assert.equal(player(done, 'p').spice, 8);
assert.equal(done.resolvedTreacheryDiscardSequence, 1);
assert.equal(
done.log.filter((e) => e.automatic?.name === 'Nullentropy Box').length,
1,
);
assert.deepEqual(inventory(done), inventory(initial));
});
void test('Box discard frames reject corrupted final pile, recovered custody, event and suspended controls atomically', () => {
const { pending } = completed();
const mutations: ((g: Game) => void)[] = [
(g) => {
g.turn++;
},
(g) => {
g.phase = 5;
},
(g) => {
g.treacheryDiscardSequence!++;
},
(g) => {
g.pendingTreacheryDiscard!.batch.cause = 'wrong';
},
(g) => {
g.pendingTreacheryDiscard!.batch.entries[0].publicFace = false;
},
(g) => {
g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = 'q';
},
(g) => {
receipt(g).player = 'q';
},
(g) => {
receipt(g).searchEvent = '';
},
(g) => {
receipt(g).searchEvent = 'different-paid-search';
},
(g) => {
receipt(g).parentSignature = 'different-parent';
},
(g) => {
receipt(g).box = 'wrong';
},
(g) => {
receipt(g).selected.name = 'forged';
},
(g) => {
receipt(g).selected = structuredClone(g.discard.at(-1)!);
},
(g) => {
g.deck.push(structuredClone(receipt(g).selected));
},
(g) => {
player(g, 'p').hand = [];
},
(g) => {
g.discard.reverse();
},
(g) => {
g.discard[0].name = 'forged';
},
(g) => {
receipt(g).finalDiscardIds.reverse();
},
(g) => {
receipt(g).finalDiscardSignature = 'wrong';
},
(g) => {
g.deck.push(structuredClone(g.discard.at(-1)!));
},
(g) => {
receipt(g).resume.decision = { kind: 'nullentropy', player: 'q' };
},
(g) => {
receipt(g).resume.response = {
kind: 'guildIncome',
owner: 'missing',
passed: [],
amount: 3,
};
},
(g) => {
receipt(g).resume.phaseOpening = {
passed: ['missing'],
initialize: false,
};
},
];
for (const key of [
'pendingNullentropy',
'response',
'decision',
'phaseOpening',
'pendingKarama',
'truthtrance',
])
mutations.push((g) => {
Object.assign(g, { [key]: {} });
});
for (const mutate of mutations) {
const corrupt = reload(pending);
mutate(corrupt);
const before = structuredClone(corrupt);
assert.throws(() => normalizeAutomaticGame(corrupt));
assert.throws(() => applyAction(corrupt, 'p', { type: 'advanceBots' }));
assert.throws(() => viewGame(corrupt, 'p'));
assert.deepEqual(corrupt, before);
}
});
void test('Box preserves a real Harkonnen hand-exchange return decision without resampling the opposing hand', () => {
let initial = fixture('harkonnen', 3);
const karama = hold(initial, 'p', 'Karama');
player(initial, 'q').hand.push(...initial.deck.splice(0, 2));
initial = applyAction(initial, 'p', {
type: 'card',
mode: 'special',
card: karama,
target: 'q',
amount: 1,
});
assert.equal(initial.decision?.kind, 'handExchange');
assert.ok(initial.pendingExchange);
const { pending } = completed(initial),
done = recover(pending);
assert.deepEqual(done.decision, initial.decision);
assert.deepEqual(done.pendingExchange, reload(initial).pendingExchange);
assert.deepEqual(done.players, pending.players);
assert.deepEqual(done.deck, pending.deck);
const returned = receipt(pending).selected.id;
const result = applyAction(done, 'p', {
type: 'decision',
returnCards: [returned],
});
assert.equal(result.pendingExchange, null);
assert.ok(player(result, 'q').hand.some((c) => c.id === returned));
assert.equal(result.treacheryDiscardSequence, 1);
assert.deepEqual(inventory(result), inventory(initial));
});
void test('Box preserves a real paid Ixian ally replacement opportunity and does not repay its auction', () => {
let initial = fixture('atreides', 3);
initial.players[1] = newPlayer('q', 'Ixians', 'ixians');
player(initial, 'q').spice = 10;
player(initial, 'p').ally = 'q';
player(initial, 'q').ally = 'p';
const purchased = initial.deck.shift()!;
initial.auction = {
cards: [purchased],
index: 0,
bid: 2,
bidder: 'p',
active: 'p',
passed: [],
opener: 0,
};
initial.decision = { kind: 'auctionPayment', player: 'p' };
initial = applyAction(initial, 'p', { type: 'decision', karama: false });
assert.equal(initial.decision?.kind, 'ixAllyCard');
const { pending } = completed(initial),
done = recover(pending);
assert.deepEqual(done.decision, initial.decision);
assert.deepEqual(done.pendingIxAlly, initial.pendingIxAlly);
assert.deepEqual(done.currentAuctionSale, initial.currentAuctionSale);
assert.equal(player(done, 'p').spice, 6);
assert.equal(player(done, 'e').spice, 10);
const result = applyAction(done, 'p', { type: 'decision', accept: true });
assert.equal(result.pendingIxAlly, null);
assert.equal(player(result, 'e').spice, 12);
assert.ok(result.discard.some((c) => c.id === purchased.id));
assert.equal(result.resolvedTreacheryDiscardSequence, 2);
assert.deepEqual(inventory(result), inventory(initial));
});
void test('Box preserves a real Ixian Ambassador card choice, then its own later discard continues once', () => {
let initial = fixture('ecaz', 5);
initial.players[1] = newPlayer('q', 'Observer', 'atreides');
initial.active = 'e';
initial.movementRemaining = ['e', 'p', 'q'];
let ambassadors = createAmbassadors(() => 0);
const chosen = ambassadors.tokens.find((t) => t.effect === 'ixians')!;
ambassadors.cohort = [
chosen,
...ambassadors.tokens.filter(
(t) => t.effect !== 'ecaz' && t.id !== chosen.id,
),
]
.slice(0, 5)
.map((t) => t.id);
for (const token of ambassadors.tokens)
token.zone =
token.effect === 'ecaz' || ambassadors.cohort.includes(token.id)
? 'supply'
: 'pool';
ambassadors = placeAmbassador(ambassadors, chosen.id, {
turn: 1,
availableSpice: 20,
destination: {
id: 'arrakeen',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
initial.ecazAmbassadors = ambassadors;
initial = applyAction(initial, 'e', {
type: 'ship',
amount: 1,
territory: 'arrakeen',
sector: 10,
});
initial = applyAction(initial, 'p', {
type: 'decision',
event: initial.pendingAmbassador!.event,
trigger: true,
beneficiary: 'p',
});
assert.equal(initial.pendingAmbassador?.stage, 'cards');
const { pending } = completed(initial),
done = recover(pending);
assert.deepEqual(done.pendingAmbassador, reload(initial).pendingAmbassador);
assert.deepEqual(done.decision, initial.decision);
assert.deepEqual(done.ecazAmbassadors, initial.ecazAmbassadors);
const selected = receipt(pending).selected.id;
const result = applyAction(done, 'p', {
type: 'decision',
event: done.pendingAmbassador!.event,
cards: [selected],
});
assert.equal(result.pendingAmbassador, null);
assert.equal(result.resolvedTreacheryDiscardSequence, 2);
assert.equal(player(result, 'p').hand.length, 1);
assert.equal(player(result, 'e').reserves, 19);
assert.deepEqual(inventory(result), inventory(initial));
});
for (const stage of ['response', 'payment'] as const)
void test(`Box restores the ${stage} of an Auditor created by an actual completed battle`, () => {
let initial = fixture('richese', 6);
initial.players[1] = newPlayer('q', 'CHOAM', 'choam');
player(initial, 'q').spice = 10;
player(initial, 'q').leaders.push(createAuditorLeader());
for (const id of ['p', 'q']) {
player(initial, id).forces = { 'arrakeen:10': 4 };
player(initial, id).reserves = 16;
}
if (stage === 'response') hold(initial, 'e', 'Karama');
initial = applyAction(initial, 'p', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'q',
});
for (let i = 0; i < 30; i++) {
if (initial.response) {
initial = applyAction(
initial,
initial.players.find((p) => !initial.response!.passed.includes(p.id))!
.id,
{ type: 'passResponse' },
);
continue;
}
const b = initial.battle!;
if (b.preLeader && !b.preLeader.closed) {
const id = [b.attacker, b.defender].find(
(id) => !b.preLeader!.ready.includes(id),
)!;
initial = applyAction(initial, id, {
type: 'battlePreparationReady',
event: b.event,
});
} else if (b.preparation)
initial = applyAction(initial, b.preparation.owner, {
type: 'declineBattlePower',
});
else break;
}
initial = applyAction(initial, 'p', {
type: 'battlePlan',
leader: player(initial, 'p').leaders[0].id,
dial: 0,
support: 0,
});
initial = applyAction(initial, 'q', {
type: 'battlePlan',
leader: CHOAM_AUDITOR_ID,
dial: 4,
support: 4,
});
initial = applyAction(initial, 'p', { type: 'traitorCall', call: false });
initial = applyAction(initial, 'q', { type: 'traitorCall', call: false });
assert.equal(initial.decision?.kind, 'choamAudit');
initial = applyAction(initial, 'q', {
type: 'decision',
event: initial.pendingAuditor!.event,
audit: true,
});
assert.equal(initial.pendingAuditor?.stage, stage);
const { pending } = completed(initial),
done = recover(pending);
assert.deepEqual(done.pendingAuditor, initial.pendingAuditor);
assert.deepEqual(done.response, initial.response);
assert.deepEqual(done.decision, initial.decision);
assert.deepEqual(done.players, pending.players);
assert.deepEqual(inventory(done), inventory(initial));
for (const id of ['p', 'q', 'e']) viewGame(pending, id);
let result = done;
while (result.response)
result = applyAction(
result,
result.players.find((p) => !result.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
assert.equal(result.decision?.kind, 'choamAuditPayment');
result = applyAction(result, 'p', {
type: 'decision',
event: result.pendingAuditor!.event,
pay: false,
});
assert.equal(result.pendingAuditor, null);
assert.equal(
result.auditorInsight?.cards[0].id,
receipt(pending).selected.id,
);
assert.equal(
result.log.filter((e) => e.automatic?.name === 'Nullentropy Box').length,
1,
);
});