Files
Dune/tests/nullentropy-box-engine-review.test.ts
2026-09-08 07:37:52 +10:00

260 lines
8.1 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
normalizeAutomaticGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
function fixture() {
const g = createGame(
'BOXREVIEW',
newPlayer('r', 'Richese', 'richese'),
true,
['choam'],
);
g.players.push(
newPlayer('a', 'Atreides', 'atreides'),
newPlayer('c', 'CHOAM', 'choam'),
);
Object.assign(g, {
status: 'playing',
phase: 3,
turn: 2,
active: 'a',
order: ['r', 'a', 'c'],
deck: baseDeck(),
richeseCache: richeseCards(),
});
for (const p of g.players) {
p.hand = [];
p.spice = 10;
p.forces = {};
}
hold(g, 'r', 'Nullentropy Box');
for (const name of ['Shield', 'Maula Pistol', 'Lasgun', 'Baliset']) {
const i = g.deck.findIndex((c) => c.name === name);
g.discard.push(...g.deck.splice(i, 1));
}
return g;
}
function hold(g: Game, owner: 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)[0];
g.players.find((p) => p.id === owner)!.hand.push(card);
return card.id;
}
const send = (g: Game, player: string, action: Action) =>
applyAction(g, player, action);
const begin = (g: Game) =>
send(g, 'r', { type: 'card', card: 'richese-nullentropy-box' });
const select = (g: Game, card = g.discard[0].id) =>
send(g, 'r', { type: 'decision', event: g.pendingNullentropy!.event, card });
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const inventory = (g: Game) =>
[
...g.deck,
...g.discard,
...g.richeseCache!,
...g.players.flatMap((p) => p.hand),
]
.map((c) => c.id)
.sort();
void test('paid Box search suspends exact response and phase opening through reload, then restores them without replaying income', () => {
let g = fixture();
hold(g, 'a', 'Karama');
g.response = { kind: 'guildIncome', owner: 'c', amount: 3, passed: ['r'] };
g.phaseOpening = { passed: ['r'], initialize: false };
const parent = structuredClone({
response: g.response,
phaseOpening: g.phaseOpening,
});
const before = inventory(g);
const pile = structuredClone(g.discard);
const picked = pile[1].id;
g = begin(g);
assert.equal(g.players[0].spice, 8);
assert.equal(g.response, null);
assert.equal(g.phaseOpening, null);
assert.equal(g.decision?.kind, 'nullentropy');
assert.deepEqual(g.discard, pile);
assert.deepEqual(g.pendingNullentropy!.resume.response, parent.response);
assert.deepEqual(
g.pendingNullentropy!.resume.phaseOpening,
parent.phaseOpening,
);
const restored = reload(g);
assert.deepEqual(normalizeAutomaticGame(restored), restored);
g = select(restored, picked);
assert.equal(g.pendingNullentropy, null);
assert.deepEqual(g.response, parent.response);
assert.deepEqual(g.phaseOpening, parent.phaseOpening);
assert.equal(g.players[0].spice, 8);
assert.equal(g.players[2].spice, 10);
assert.deepEqual(
g.players[0].hand.map((c) => c.id),
[picked],
);
assert.equal(g.discard.at(-1)!.id, 'richese-nullentropy-box');
assert.deepEqual(inventory(g), before);
});
void test('search locks Truthtrance, early CHOAM cash-in, Distrans and gameplay passes while seat takeback remains usable', () => {
let g = fixture();
const truth = hold(g, 'a', 'Truthtrance');
hold(g, 'c', 'Distrans');
const give = hold(g, 'c', 'Snooper');
g = begin(g);
const actions: [string, Action][] = [
['a', { type: 'card', card: truth }],
['c', { type: 'card', mode: 'special', cards: [give] }],
['c', { type: 'card', card: 'richese-distrans', target: 'a', give }],
['r', { type: 'ready' }],
['a', { type: 'passResponse' }],
];
for (const [actor, action] of actions) {
const before = structuredClone(g);
assert.throws(() => send(g, actor, action), /search|Nullentropy|Box/i);
assert.deepEqual(g, before);
}
const pile = structuredClone(g.discard),
pending = structuredClone(g.pendingNullentropy);
g = send(g, 'r', { type: 'setAutopilot', difficulty: 'Hard' });
assert.deepEqual(g.discard, pile);
assert.deepEqual(g.pendingNullentropy, pending);
assert.equal(g.players[0].spice, 8);
g = send(g, 'r', { type: 'setAutopilot', difficulty: null });
assert.deepEqual(g.pendingNullentropy, pending);
assert.deepEqual(normalizeAutomaticGame(g), g);
});
void test('a suspended automatic Harkonnen bonus cannot recycle an empty-deck discard pile during inspection', () => {
let g = fixture();
g.players[1].faction = 'harkonnen';
g.deck = [];
g.response = { kind: 'harkonnenBonus', owner: 'a', passed: [] };
// This is a legacy automatically-resolvable response; search must still hold it.
g = begin(g);
const before = reload(g);
assert.equal(g.deck.length, 0);
assert.equal(g.players[1].hand.length, 0);
assert.deepEqual(normalizeAutomaticGame(before), before);
assert.equal(viewGame(before, 'r').nullentropy!.search!.cards.length, 4);
assert.equal(g.discard.length, 4);
});
void test('stale event or corrupted discard evidence cannot reveal candidates or consume randomness', (t) => {
const opened = reload(begin(fixture()));
let draws = 0;
t.mock.method(crypto, 'getRandomValues', () => {
draws++;
throw new Error('Unexpected RNG');
});
const stale = structuredClone(opened);
assert.throws(
() =>
send(stale, 'r', {
type: 'decision',
event: 'stale',
card: stale.discard[0].id,
}),
/event|search|stale/i,
);
assert.deepEqual(stale, opened);
for (const change of [
(g: Game) => {
g.discard[0].name = 'Corrupt face';
},
(g: Game) => {
g.discard.reverse();
},
(g: Game) => {
g.pendingNullentropy!.discardIds.pop();
},
(g: Game) => {
g.players[0].hand = [];
},
]) {
const g = structuredClone(opened);
change(g);
const before = structuredClone(g);
const view = viewGame(g, 'r');
assert.ok(
!view.nullentropy?.search || view.nullentropy.search.cards.length === 0,
);
assert.throws(
() => select(g),
/search|discard|Box|card|custody|changed|signature/i,
);
assert.deepEqual(g, before);
}
assert.equal(draws, 0);
});
void test('the two-spice fee cannot make a real Truthtrance support promise impossible or grant a failed paid peek', () => {
let g = fixture();
g.phase = 6;
g.storm = 18;
g.players[0].spice = 3;
for (const p of g.players.slice(0, 2)) {
p.forces = { 'arrakeen:10': 4 };
p.reserves = 16;
}
g.battle = {
territory: 'arrakeen',
attacker: 'r',
defender: 'a',
prepared: true,
plans: {},
revealed: false,
traitorCalls: {},
};
const truth = hold(g, 'a', 'Truthtrance');
g = send(g, 'a', { type: 'card', card: truth });
while (g.truthtrance!.stage === 'priority')
g = send(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
g = send(g, 'a', {
type: 'truthAsk',
question: {
kind: 'battlePlan',
target: 'r',
claim: { kind: 'support', compare: 'gte', value: 2 },
},
});
g = send(g, 'r', { type: 'truthAnswer', answer: 'yes' });
const before = structuredClone(g);
assert.throws(() => begin(g), /promise|Truthtrance|truthful/i);
assert.deepEqual(g, before);
const view = viewGame(g, 'r').nullentropy!;
assert.equal(view.search, null);
assert.ok(view.blocked);
assert.equal(g.players[0].spice, 3);
});
void test('a Box committed to a real pending Richese gift cannot start a paid search', () => {
let g = fixture();
g.players[0].ally = 'a';
g.players[1].ally = 'r';
hold(g, 'c', 'Karama');
g = send(g, 'r', { type: 'richeseGift', card: 'richese-nullentropy-box' });
assert.equal(g.response?.kind, 'richeseGift');
const before = structuredClone(g);
assert.throws(() => begin(g), /reserved|gift|committed/);
assert.deepEqual(g, before);
assert.equal(viewGame(g, 'r').nullentropy!.search, null);
});