Files
Dune/tests/karama-promise-preflight.test.ts
T
2026-09-08 07:37:52 +10:00

307 lines
11 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 ts from 'typescript';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import type { PlanClaim } from '../game/battle-promises';
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
// Observe the production dispatcher before its wrapper's final reconciliation.
// A durable cost must not rely exclusively on that later atomic boundary.
const observed: {
applyActionInner?: (g: Game, id: string, a: Action) => Game;
} = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
'\nexport {applyActionInner};\n',
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports: observed,
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
crypto,
structuredClone,
TextEncoder,
JSON,
},
);
function act(g: Game, id: string, a: Action) {
const before = structuredClone(g);
const next = applyAction(g, id, a);
assert.deepEqual(g, before);
return reload(next);
}
function hold(g: Game, id: string, kind: string) {
const i = g.deck.findIndex(
(c) => c.name === kind || c.kind === kind || c.effect === kind,
);
assert.ok(i >= 0, kind);
const card = g.deck.splice(i, 1)[0];
player(g, id).hand.push(card);
return card;
}
function fixture(battle = false) {
const g = createGame(
'KARAMATRUTH',
newPlayer('p', 'Promisor', battle ? 'beneGesserit' : 'harkonnen'),
battle,
[],
);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('f', 'Other', battle ? 'harkonnen' : 'fremen'),
);
Object.assign(g, {
status: 'playing',
phase: battle ? 6 : 5,
turn: 2,
storm: 18,
active: 'p',
order: ['p', 'e', 'f'],
movementRemaining: ['p', 'e', 'f'],
phaseOpening: null,
deck: baseDeck(),
});
for (const p of g.players) {
p.hand = [];
p.forces = {};
p.reserves = 20;
p.tanks = 0;
p.spice = 20;
p.traitors = [];
p.traitorChoices = [];
}
player(g, 'e').ally = 'f';
player(g, 'f').ally = 'e';
hold(g, 'e', 'Truthtrance');
return g;
}
function bind(initial: Game, claim?: PlanClaim) {
let g = act(initial, 'e', {
type: 'card',
card: player(initial, 'e').hand.find((c) => c.effect === 'truthtrance')!.id,
});
while (g.truthtrance?.stage === 'priority')
g = act(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
g = act(g, 'e', {
type: 'truthAsk',
question: claim
? { kind: 'battlePlan', target: 'p', claim }
: { kind: 'shipment', target: 'p', territory: 'carthag', minimum: 6 },
});
return act(g, 'p', { type: 'truthAnswer', answer: 'yes' });
}
function battleFixture(alternative = false) {
let g = fixture(true);
for (const id of ['p', 'f']) {
player(g, id).forces = { 'arrakeen:10': 4 };
player(g, id).reserves = 16;
}
const cost = hold(g, 'p', 'worthless'),
counter = hold(g, 'f', 'karama');
const shield = alternative ? hold(g, 'p', 'Shield') : null;
g = act(g, 'p', { type: 'chooseBattle', territory: 'arrakeen', target: 'f' });
assert.equal(g.battle?.preparation?.kind, 'voice');
g = act(g, 'p', { type: 'declineBattlePower' });
const claim: PlanClaim = alternative
? {
kind: 'or',
terms: [
{ kind: 'weapon', name: cost.name },
{ kind: 'defense', name: 'Shield' },
],
}
: { kind: 'weapon', name: cost.name };
g = bind(g, claim);
g = act(g, 'e', { type: 'emperorGift', amount: 1 });
assert.equal(g.response?.kind, 'emperorGift');
return { g, cost, counter, shield };
}
function rejectsBeforeCost(g: Game, card: string) {
const a: Action = { type: 'card', card, mode: 'cancel' };
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'p', a), /Truthtrance/);
assert.throws(() => observed.applyActionInner!(g, 'p', a), /Truthtrance/);
assert.deepEqual(g, before);
assert.ok(player(g, 'p').hand.some((c) => c.id === card));
assert.equal(
g.discard.some((c) => c.id === card),
false,
);
}
function passKind(g: Game, kind: NonNullable<Game['response']>['kind']) {
for (let i = 0; g.response?.kind === kind; i++) {
assert.ok(i < 12);
g = act(g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, {
type: 'passResponse',
});
}
return g;
}
void test('the actual dispatcher rejects spending the last shipment Karama on an unrelated real cancellation before the cost boundary', (t) => {
const initial = fixture();
player(initial, 'p').spice = 3;
const cost = hold(initial, 'p', 'Karama');
let g = bind(initial);
g = act(g, 'e', { type: 'emperorGift', amount: 1 });
assert.equal(g.response?.kind, 'emperorGift');
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw new Error('Unexpected random draw');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw new Error('Unexpected event allocation');
});
rejectsBeforeCost(g, cost.id);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
void test('the actual intended own-shipment rate preserves the promise after consuming its required Karama', () => {
const initial = fixture();
player(initial, 'p').spice = 3;
const cost = hold(initial, 'p', 'Karama');
const g = bind(initial);
const a: Action = {
type: 'card',
card: cost.id,
mode: 'shipment',
target: 'p',
};
const inner = observed.applyActionInner!(g, 'p', a);
assert.equal(inner.karamaShipping?.player, 'p');
assert.equal(inner.discard.filter((c) => c.id === cost.id).length, 1);
let done = act(g, 'p', a);
const completion = viewGame(done, 'p').shipmentCompletion!;
assert.ok(completion);
assert.equal(completion.actions[0].type, 'ship');
for (const action of completion.actions) done = act(done, 'p', action);
assert.equal(done.shipmentPromises![0].fulfilled, true);
assert.equal(player(done, 'p').forces['carthag:11'], 6);
assert.equal(player(done, 'p').spice, 0);
assert.equal(done.discard.filter((c) => c.id === cost.id).length, 1);
});
void test('BG cannot commit a promised sole Worthless battle card even when its proposed cancellation is otherwise legal', (t) => {
const { g, cost } = battleFixture();
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw new Error('Unexpected random draw');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw new Error('Unexpected event allocation');
});
rejectsBeforeCost(g, cost.id);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
void test('BG may consume one promised alternative while another legal plan survives either allowance or counter-cancellation', () => {
const { g, cost, counter, shield } = battleFixture(true);
for (const blocked of [false, true]) {
let next = act(g, 'p', { type: 'card', card: cost.id, mode: 'cancel' });
assert.equal(next.response?.kind, 'worthlessKarama');
assert.equal(next.battle!.truthPromises![0].released, undefined);
if (blocked) {
next = act(next, 'f', { type: 'card', card: counter.id, mode: 'cancel' });
next = passKind(next, 'emperorGift');
} else next = passKind(next, 'worthlessKarama');
assert.equal(next.battle!.truthPromises![0].released, undefined);
const ownerView = viewGame(next, 'p');
assert.equal(ownerView.battle!.compliantPlan?.defense, shield!.id);
assert.equal(viewGame(next, 'e').battle!.compliantPlan, null);
assert.equal(player(next, 'e').spice, blocked ? 19 : 20);
assert.equal(player(next, 'f').spice, blocked ? 21 : 20);
const plan = ownerView.battle!.compliantPlan!;
next = act(next, 'p', { type: 'battlePlan', ...plan });
assert.equal(next.battle!.plans.p.defense, shield!.id);
assert.equal(viewGame(next, 'e').battle!.plans.p, undefined);
assert.equal(next.discard.filter((c) => c.id === cost.id).length, 1);
}
});
void test('repeated owner-only completion searches use no RNG and preserve a Ghola plus Karama preparation witness', (t) => {
const initial = fixture();
player(initial, 'p').spice = 3;
player(initial, 'p').reserves = 1;
player(initial, 'p').tanks = 19;
const karama = hold(initial, 'p', 'Karama'),
ghola = hold(initial, 'p', 'Tleilaxu Ghola');
const g = bind(initial);
const before = structuredClone(g);
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw new Error('Unexpected random draw');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw new Error('Unexpected event allocation');
});
const math = t.mock.method(Math, 'random', () => {
throw new Error('Unexpected random draw');
});
let expected: unknown;
for (let i = 0; i < 8; i++) {
const view = viewGame(reload(g), 'p');
assert.ok(view.shipmentCompletion);
assert.ok(
view.shipmentCompletion.actions.some((a) => a.card === karama.id),
);
assert.ok(view.shipmentCompletion.actions.some((a) => a.card === ghola.id));
if (i) assert.deepEqual(view.shipmentCompletion, expected);
expected = view.shipmentCompletion;
for (const id of ['e', 'f']) {
const other = viewGame(reload(g), id);
assert.equal(other.shipmentCompletion, null);
assert.equal(other.truthShipmentAnswers, null);
assert.equal(other.players.find((p) => p.id === 'p')!.hand, undefined);
}
}
assert.deepEqual(g, before);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
assert.equal(math.mock.callCount(), 0);
});
void test('the intended printed Voice cancellation restores a promised Shield before owner feasibility is checked', () => {
let g = fixture(true);
g.players[0] = newPlayer('p', 'Promisor', 'harkonnen');
g.players[2] = newPlayer('f', 'Voice owner', 'beneGesserit');
player(g, 'f').ally = 'e';
for (const id of ['p', 'f']) {
player(g, id).forces = { 'arrakeen:10': 4 };
player(g, id).reserves = 16;
player(g, id).spice = 20;
}
const cost = hold(g, 'p', 'Karama'),
shield = hold(g, 'p', 'Shield');
g = act(g, 'p', { type: 'chooseBattle', territory: 'arrakeen', target: 'f' });
assert.equal(g.battle?.preparation?.kind, 'voice');
g = bind(g, { kind: 'defense', name: 'Shield' });
g = act(g, 'f', { type: 'voice', kind: 'shield', must: false });
assert.equal(g.response?.kind, 'voice');
assert.equal(g.battle!.truthPromises![0].released, undefined);
const action: Action = { type: 'card', card: cost.id, mode: 'cancel' };
const inner = observed.applyActionInner!(g, 'p', action);
assert.equal(inner.battle!.voice, undefined);
assert.equal(inner.battle!.truthPromises![0].released, undefined);
g = act(g, 'p', action);
const completion = viewGame(g, 'p').battle!.compliantPlan!;
assert.equal(completion.defense, shield.id);
g = act(g, 'p', { type: 'battlePlan', ...completion });
assert.equal(g.battle!.plans.p.defense, shield.id);
});