Files
Dune/tests/ordinary-card-discard-continuations.test.ts

1078 lines
34 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, treacheryDeck } from '../game/cards';
import { createTechTokens } from '../game/tech-tokens';
import { createStormSource } from '../game/discovery-storm';
import {
createDukeVidal,
acquireDuke,
DUKE_VIDAL_ID,
} from '../game/duke-vidal';
import type { FactionId } from '../game/catalog';
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));
/** 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;
} = {};
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: 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;
}
function fixture(
phase = 4,
advanced = false,
factions: FactionId[] = ['emperor', 'harkonnen', 'atreides'],
) {
const g = createGame(
'ORDINARYFRAME',
newPlayer('p', 'Player', factions[0]),
advanced,
);
g.players.push(
newPlayer('q', 'Other', factions[1]),
newPlayer('t', 'Third', factions[2]),
);
Object.assign(g, {
status: 'playing',
phase,
turn: 2,
storm: 18,
active: phase === 5 ? 'p' : null,
order: ['p', 'q', 't'],
deck: baseDeck(),
movementRemaining: phase === 5 ? ['p', 'q', 't'] : undefined,
});
for (const p of g.players) {
p.hand = [];
p.traitorChoices = [];
p.traitors = [];
p.spice = 10;
p.forces = {};
p.reserves = 20;
}
return g;
}
function hold(g: Game, id: string, effect: string) {
const i = g.deck.findIndex(
(c) => c.effect === effect || c.id === effect || c.kind === effect,
);
assert.ok(i >= 0, effect);
const card = g.deck.splice(i, 1)[0];
player(g, id).hand.push(card);
return card.id;
}
function receipt(g: Game) {
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind !== 'ordinaryCardDiscard')
throw Error('Missing ordinary card frame');
return c;
}
function inventory(g: Game) {
const ids = [
...g.deck,
...g.discard,
...(g.removedTreachery ?? []),
...g.players.flatMap((p) => p.hand),
...(g.richeseCache ?? []),
...(g.ornithopter ? [g.ornithopter.card] : []),
]
.map((c) => c.id)
.sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
function conserved(g: Game) {
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
assert.ok(p.spice >= 0);
if (p.elites) {
assert.ok(p.elites.reserves <= p.reserves);
assert.ok(p.elites.tanks <= p.tanks);
for (const [key, n] of Object.entries(p.elites.forces))
assert.ok(n <= (p.forces[key] ?? 0));
}
}
}
function recover(g: Game) {
const before = structuredClone(g),
done = normalizeAutomaticGame(reload(g));
assert.deepEqual(g, before);
assert.equal(done.pendingTreacheryDiscard, null);
assert.deepEqual(reload(normalizeAutomaticGame(reload(done))), reload(done));
return done;
}
function play(initial: Game, id: string, action: Action) {
const pending = inner(initial, id, action),
c = receipt(pending);
assert.equal(c.player, id);
assert.equal(c.card, action.card);
assert.equal(pending.pendingTreacheryDiscard!.batch.entries.length, 1);
const e = pending.pendingTreacheryDiscard!.batch.entries[0];
assert.equal(e.card.id, action.card);
assert.equal(e.discardedBy, id);
assert.equal(e.publicFace, true);
assert.deepEqual(inventory(pending), inventory(initial));
conserved(pending);
const done = recover(pending);
assert.deepEqual(reload(done), reload(applyAction(initial, id, action)));
return { pending, done };
}
function allow(initial: Game) {
let g = initial;
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
function ready(initial: Game) {
let g = initial;
for (const p of g.players)
if (!g.ready.includes(p.id)) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
void test('Hajr retires after granting one remaining move and cannot grant a third on recovery', () => {
let g = fixture(5);
const card = hold(g, 'p', 'hajr');
player(g, 'p').forces = { 'imperial_basin:10': 3 };
player(g, 'p').reserves = 17;
g = applyAction(g, 'p', {
type: 'move',
forces: { 'imperial_basin:10': 1 },
territory: 'arrakeen',
sector: 10,
});
assert.equal(player(g, 'p').moved, 1);
const { pending, done } = play(g, 'p', { type: 'card', card });
assert.deepEqual(pending.hajr, ['p']);
assert.equal(player(done, 'p').moved, 1);
assert.equal(done.active, 'p');
assert.equal(
done.log.filter((e) => e.text === 'Player played Hajr.').length,
1,
);
const next = applyAction(done, 'p', {
type: 'move',
forces: { 'imperial_basin:10': 1 },
territory: 'carthag',
sector: 11,
});
assert.equal(player(next, 'p').moved, 2);
assert.throws(() =>
applyAction(next, 'p', {
type: 'move',
forces: { 'imperial_basin:10': 1 },
territory: 'arrakeen',
sector: 10,
}),
);
assert.throws(() => applyAction(done, 'p', { type: 'card', card }));
conserved(next);
});
for (const afterDials of [false, true])
void test(`Weather Control ${afterDials ? 'after revealed dials' : 'before dialing at zero'} restores the selected distance without moving the storm`, () => {
let g = fixture(0);
g.storm = 1;
g.stormDialers = ['p', 'q'];
const card = hold(g, 'p', 'weather');
if (afterDials) {
g = applyAction(g, 'p', { type: 'stormDial', amount: 2 });
g = applyAction(g, 'q', { type: 'stormDial', amount: 3 });
g = applyAction(g, 't', { type: 'ready' });
}
const amount = afterDials ? 2 : 0,
{ pending, done } = play(g, 'p', { type: 'card', card, amount });
assert.equal(pending.stormPending, amount);
assert.equal(pending.storm, 1);
assert.deepEqual(pending.ready, []);
assert.deepEqual(done.stormDials, g.stormDials);
assert.equal(done.storm, 1);
const next = ready(done);
assert.equal(next.storm, 1 + amount);
assert.equal(next.phase, 1);
assert.equal(next.stormPending, null);
});
function harvest(storm = false) {
let g = fixture(1);
g.deck = treacheryDeck(['ix']);
g.spiceDeck = [{ territory: 'red_chasm', sector: 7, amount: 8 }];
g.spice = { 'red_chasm:7': 3 };
if (storm) {
g.storm = 7;
g.spice = {};
}
const first = hold(g, 'p', 'harvester'),
second = hold(g, 'q', 'ix-harvester');
g = ready(g);
return { g, first, second };
}
void test('two physical Harvesters retire separate doubles of only the fresh blow, preserving older spice', () => {
const initial = harvest();
assert.equal(initial.g.spice['red_chasm:7'], 11);
const first = play(initial.g, 'p', { type: 'card', card: initial.first });
assert.equal(first.pending.spice['red_chasm:7'], 19);
assert.equal(first.pending.spiceWindow!.amount, 16);
const accepted = applyAction(first.done, 'p', { type: 'ready' }),
second = play(accepted, 'q', { type: 'card', card: initial.second });
assert.equal(second.pending.spice['red_chasm:7'], 35);
assert.equal(second.pending.spiceWindow!.amount, 32);
assert.equal(second.pending.spiceWindow!.harvesters, 2);
assert.deepEqual(second.pending.ready, []);
assert.equal(second.done.resolvedTreacheryDiscardSequence, 2);
assert.equal(second.done.spice['red_chasm:7'], 35);
assert.equal(ready(second.done).spiceWindow, null);
});
void test('a Harvester in the storm records its completed blow without creating ground spice', () => {
const { g, first } = harvest(true),
{ pending, done } = play(g, 'p', { type: 'card', card: first });
assert.equal(pending.spice['red_chasm:7'], undefined);
assert.equal(done.spice['red_chasm:7'], undefined);
assert.equal(done.spiceWindow!.amount, 16);
});
void test('Advanced Family Atomics removes its card instead of opening a discard after killing all typed Wall groups', () => {
const g = fixture(0, true, ['emperor', 'fremen', 'atreides']),
card = hold(g, 'p', 'atomics');
player(g, 'p').forces = { 'shield_wall:8': 2, 'arrakeen:10': 1 };
player(g, 'p').reserves = 17;
player(g, 'p').elites = {
forces: { 'shield_wall:8': 1 },
reserves: 4,
tanks: 0,
revived: 0,
};
player(g, 'q').forces = { 'shield_wall:8': 3 };
player(g, 'q').reserves = 17;
player(g, 'q').elites = {
forces: { 'shield_wall:8': 2 },
reserves: 1,
tanks: 0,
revived: 0,
};
g.stormPending = 1;
g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending);
g.ready = ['t'];
const original = inventory(g), done = applyAction(g, 'p', { type: 'card', card });
assert.equal(done.shieldWallDestroyed, true);
assert.deepEqual(done.ready, []);
assert.equal(done.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(done.removedTreachery?.map(removed => removed.id), [card]);
assert.equal(done.discard.some(discarded => discarded.id === card), false);
assert.deepEqual(inventory(done), original);
for (const [id, total, elite] of [
['p', 2, 1],
['q', 3, 2],
] as const) {
assert.equal(player(done, id).forces['shield_wall:8'], undefined);
assert.equal(player(done, id).tanks, total);
assert.equal(player(done, id).elites!.tanks, elite);
}
assert.equal(player(done, 'p').forces['arrakeen:10'], 1);
const after = ready(done);
assert.equal(player(after, 'p').tanks, 2);
assert.equal(player(after, 'q').tanks, 3);
conserved(after);
});
for (const typed of [false, true])
void test(`Ghola ${typed ? 'typed' : 'ordinary'} forces return once without consuming normal revival usage`, () => {
const g = fixture(4, typed),
card = hold(g, 'p', 'ghola');
player(g, 'p').tanks = 5;
player(g, 'p').reserves = 15;
player(g, 'p').revived = 1;
if (typed)
player(g, 'p').elites = { reserves: 3, tanks: 2, forces: {}, revived: 0 };
const amount = typed ? 2 : 5,
{ pending, done } = play(g, 'p', {
type: 'card',
card,
amount,
elite: typed ? 1 : 0,
});
assert.equal(player(pending, 'p').tanks, 5 - amount);
assert.equal(player(done, 'p').reserves, 15 + amount);
assert.equal(player(done, 'p').revived, 1);
assert.equal(player(done, 'p').spice, 10);
if (typed) {
assert.equal(player(done, 'p').elites!.tanks, 1);
assert.equal(player(done, 'p').elites!.reserves, 4);
assert.equal(player(done, 'p').elites!.revived, 1);
assert.throws(() =>
applyAction(done, 'p', { type: 'revive', amount: 1, elite: 1 }),
);
}
});
for (const kind of ['leader', 'kwisatz'] as const)
void test(`Ghola ${kind} recovery preserves original custody and increments only the actual KH cycle`, () => {
const g = fixture(4, true, ['atreides', 'harkonnen', 'emperor']),
card = hold(g, 'p', 'ghola'),
p = player(g, 'p');
p.leaderRevived = true;
p.revivalCycle = 2;
p.leaders[0].dead = true;
p.leaders[0].deaths = 2;
p.leaders[0].concealed = { captor: 'q', dead: false, deaths: 1 };
p.kwisatz = { dead: true, revivalCycle: 3 };
const id = kind === 'leader' ? p.leaders[0].id : 'kwisatz',
{ pending, done } = play(g, 'p', { type: 'card', card, leader: id });
assert.equal(player(done, 'p').leaderRevived, true);
assert.equal(player(done, 'p').revivalCycle, 2);
assert.equal(player(done, 'p').spice, 10);
if (kind === 'leader') {
assert.equal(player(done, 'p').leaders[0].dead, false);
assert.equal(player(done, 'p').leaders[0].deaths, 2);
assert.equal(player(done, 'p').leaders[0].concealed, undefined);
assert.equal(player(done, 'p').kwisatz!.dead, true);
} else {
assert.equal(player(done, 'p').kwisatz!.dead, false);
assert.equal(player(done, 'p').kwisatz!.revivalCycle, 4);
assert.equal(player(done, 'p').leaders[0].dead, true);
}
assert.deepEqual(done.players, pending.players);
});
for (const cancel of [false, true])
void test(`Ghola's restored Tleilaxu income ${cancel ? 'cancels' : 'pays'} once after the already-earned Axlotl amount`, () => {
const g = fixture(4, true, ['emperor', 'harkonnen', 'tleilaxu']),
card = hold(g, 'p', 'ghola'),
karama = hold(g, 'q', 'karama');
player(g, 'p').tanks = 5;
player(g, 'p').reserves = 15;
g.techTokens = createTechTokens(g.players);
g.techTokens.axlotl.owner = 'q';
g.techTokens.heighliners.owner = 'q';
g.revivalFreeIncome = { p: g.turn };
const { pending, done } = play(g, 'p', { type: 'card', card, amount: 5 });
assert.equal(pending.response, null);
assert.deepEqual(receipt(pending).resume.response, {
kind: 'revivalIncome',
owner: 't',
recipient: 'p',
amount: 1,
passed: [],
});
assert.equal(pending.techTokens!.axlotl.spice, 2);
assert.equal(pending.techTokens!.axlotl.triggeredTurn, g.turn);
assert.equal(player(done, 'q').spice, 10);
assert.equal(player(done, 't').spice, 10);
assert.deepEqual(done.revivalFreeIncome, g.revivalFreeIncome);
const resolved = cancel
? applyAction(done, 'q', { type: 'card', card: karama, mode: 'cancel' })
: allow(done);
assert.equal(player(resolved, 't').spice, cancel ? 10 : 11);
assert.equal(player(resolved, 'p').tanks, 0);
assert.equal(resolved.techTokens!.axlotl.spice, 2);
const end = ready(resolved);
assert.equal(player(end, 'q').spice, 12);
assert.equal(end.techTokens!.axlotl.spice, 0);
conserved(end);
});
void test('Ghola outside Revival automatically pays Tleilaxu once, while self-Ghola does not accrue Axlotl', () => {
for (const self of [false, true]) {
const g = fixture(
self ? 4 : 8,
true,
self
? ['tleilaxu', 'harkonnen', 'emperor']
: ['emperor', 'harkonnen', 'tleilaxu'],
),
card = hold(g, 'p', 'ghola');
player(g, 'p').tanks = 1;
player(g, 'p').reserves = 19;
g.techTokens = createTechTokens(g.players);
g.techTokens.axlotl.owner = 'q';
const { pending, done } = play(g, 'p', { type: 'card', card, amount: 1 });
assert.equal(pending.techTokens!.axlotl.spice, 0);
assert.ok(receipt(pending).resume.response);
assert.equal(done.response, null);
assert.equal(player(done, self ? 'p' : 't').spice, 11);
assert.equal(player(done, 'p').reserves, 20);
}
});
void test('a real acquired foreign leader stays in its original pool when its controlling Tleilaxu uses Ghola', () => {
let g = fixture(4, true, ['atreides', 'harkonnen', 'tleilaxu']);
player(g, 'p').leaders[0].dead = true;
player(g, 'p').leaders[0].deaths = 1;
player(g, 't').leaders[0].dead = true;
player(g, 't').leaders[0].deaths = 1;
g = allow(
applyAction(g, 't', {
type: 'reviveForeignGhola',
leader: player(g, 'p').leaders[0].id,
}),
);
assert.equal(player(g, 'p').leaders[0].gholaBy, 't');
// A later death is the isolated scenario precondition; acquisition above is a real action.
player(g, 'p').leaders[0].dead = true;
player(g, 'p').leaders[0].deaths = 2;
g.phase = 7;
const card = hold(g, 'p', 'ghola'),
before = structuredClone(g),
leader = player(g, 'p').leaders[0].id;
assert.throws(() => applyAction(g, 'p', { type: 'card', card, leader }));
assert.deepEqual(g, before);
player(g, 't').hand.push(player(g, 'p').hand.pop()!);
const captured = reload(g);
player(captured, 'p').leaders[0].capturedBy = 't';
assert.throws(() =>
applyAction(captured, 't', { type: 'card', card, leader }),
);
const { pending, done } = play(g, 't', { type: 'card', card, leader });
assert.equal(player(done, 'p').leaders[0].dead, false);
assert.equal(player(done, 'p').leaders[0].gholaBy, 't');
assert.equal(player(done, 'p').leaders[0].deaths, 2);
assert.equal(
player(done, 't').leaders.some((l) => l.id === leader),
false,
);
assert.deepEqual(
done.players.map((p) => p.leaders),
pending.players.map((p) => p.leaders),
);
});
function ask(initial: Game, question: Record<string, unknown>) {
let g = initial;
const card = hold(g, 'q', 'truthtrance');
g = applyAction(g, 'q', { type: 'card', card });
while (g.truthtrance!.stage === 'priority')
g = applyAction(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
return applyAction(g, 'q', { type: 'truthAsk', question });
}
void test('shipment search keeps its Ghola plus escrow and Karama witness immutable, private and executable', () => {
let g = fixture(5);
const p = player(g, 'p');
p.reserves = 1;
p.tanks = 5;
p.forces = { 'arrakeen:10': 14 };
p.spice = 3;
p.ally = 't';
player(g, 't').ally = 'p';
const ghola = hold(g, 'p', 'ghola'),
karama = hold(g, 'p', 'karama');
g = applyAction(g, 'p', { type: 'pledgeAid', amount: 2 });
g = ask(g, {
kind: 'shipment',
target: 'p',
territory: 'carthag',
minimum: 6,
});
assert.deepEqual(viewGame(g, 'p').truthShipmentAnswers, ['yes', 'no']);
g = applyAction(g, 'p', { type: 'truthAnswer', answer: 'yes' });
const snapshot = structuredClone(g),
first = viewGame(g, 'p').shipmentCompletion;
assert.ok(first?.actions.some((a) => a.card === ghola));
assert.deepEqual(viewGame(g, 'p').shipmentCompletion, first);
assert.equal(viewGame(g, 'q').shipmentCompletion, null);
assert.equal(viewGame(g, 't').shipmentCompletion, null);
assert.deepEqual(g, snapshot);
const beforeDiversion = structuredClone(g);
assert.throws(
() => applyAction(g, 'p', { type: 'card', card: ghola, amount: 1 }),
/voluntar/,
);
assert.deepEqual(g, beforeDiversion);
const actions: Action[] = [];
let frameSeen = false;
for (let i = 0; i < 5 && !player(g, 'p').shipped; i++) {
const action = viewGame(g, 'p').shipmentCompletion!.actions[0];
actions.push(action);
if (action.card === ghola) {
const observed = play(g, 'p', action);
g = observed.done;
frameSeen = true;
} else g = applyAction(g, 'p', action);
}
assert.ok(frameSeen);
assert.ok(actions.some((a) => a.type === 'pledgeAid'));
assert.ok(actions.some((a) => a.card === karama));
assert.equal(player(g, 'p').forces['carthag:11'], 6);
assert.equal(player(g, 'p').tanks, 0);
assert.equal(player(g, 'p').spice, 0);
assert.equal(g.shipmentPromises![0].fulfilled, true);
conserved(g);
});
function supportPromise() {
let g = fixture(6, true, ['tleilaxu', 'harkonnen', 'emperor']);
player(g, 'p').spice = 0;
player(g, 'p').tanks = 1;
player(g, 'p').reserves = 15;
player(g, 'p').forces = { 'arrakeen:10': 4 };
player(g, 't').reserves = 16;
player(g, 't').forces = { 'arrakeen:10': 4 };
hold(g, 'p', 'ghola');
hold(g, 'q', 'karama');
g.active = 'p';
g = allow(
applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 't',
}),
);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
const claim: PlanClaim = { kind: 'support', compare: 'gte', value: 1 };
g = ask(g, { kind: 'battlePlan', target: 'p', territory: 'arrakeen', claim });
return applyAction(g, 'p', { type: 'truthAnswer', answer: 'yes' });
}
for (const cancel of [false, true])
void test(`battle preparation preserves conditional Ghola income until the restored response ${cancel ? 'cancels' : 'pays'}`, () => {
const g = supportPromise(),
snapshot = structuredClone(g),
preparation = viewGame(g, 'p').battle!.compliantPreparation!;
assert.equal(preparation.actions.length, 1);
assert.equal(preparation.actions[0].amount, 1);
assert.equal(viewGame(g, 'q').battle!.compliantPreparation, null);
assert.deepEqual(
viewGame(g, 'p').battle!.compliantPreparation,
preparation,
);
assert.deepEqual(g, snapshot);
const { pending, done } = play(g, 'p', preparation.actions[0]);
assert.equal(pending.battle!.truthPromises![0].released, undefined);
assert.equal(player(pending, 'p').spice, 0);
for (const p of pending.players) {
const view = viewGame(pending, p.id);
assert.equal(view.automaticContinuationPending, true);
assert.equal(view.battle!.compliantPreparation, null);
}
assert.equal(
viewGame(done, 'p').battle!.compliantPreparation!.waitingForIncome,
true,
);
assert.equal(done.battle!.truthPromises![0].released, undefined);
const resolved = cancel
? applyAction(done, 'q', {
type: 'card',
card: player(done, 'q').hand.find((c) => c.effect === 'karama')!.id,
mode: 'cancel',
})
: allow(done);
assert.equal(player(resolved, 'p').tanks, 0);
assert.equal(player(resolved, 'p').spice, cancel ? 0 : 1);
assert.equal(
resolved.battle!.truthPromises![0].released,
cancel ? true : undefined,
);
if (!cancel) {
const plan = viewGame(resolved, 'p').battle!.compliantPlan!;
assert.ok(plan);
assert.equal(
applyAction(resolved, 'p', { type: 'battlePlan', ...plan }).battle!
.plans.p.support,
1,
);
}
});
void test('Ixian Ghola retains the cyborg exception without consuming the normal revival allowance', () => {
const g = fixture(4, true, ['ixians', 'harkonnen', 'emperor']),
card = hold(g, 'p', 'ghola'),
p = player(g, 'p');
p.tanks = 4;
p.reserves = 16;
p.revived = 3;
p.elites = { tanks: 4, reserves: 3, forces: {}, revived: 3 };
const { pending, done } = play(g, 'p', {
type: 'card',
card,
amount: 4,
elite: 4,
});
assert.equal(player(done, 'p').elites!.reserves, 7);
assert.equal(player(done, 'p').elites!.tanks, 0);
assert.equal(player(done, 'p').revived, 3);
assert.deepEqual(done.players, pending.players);
});
function simpleFrame(
effect: 'hajr' | 'weather' | 'harvester' | 'atomics' | 'ghola',
) {
const phase =
effect === 'hajr'
? 5
: effect === 'harvester'
? 1
: effect === 'ghola'
? 4
: 0,
g = fixture(phase),
card = hold(g, 'p', effect);
if (effect === 'harvester') {
g.spiceWindow = {
territory: 'red_chasm',
sector: 7,
amount: 8,
harvested: false,
};
g.spice = { 'red_chasm:7': 11 };
}
if (effect === 'atomics') {
player(g, 'p').forces = { 'shield_wall:8': 1 };
player(g, 'p').reserves = 19;
}
if (effect === 'ghola') {
player(g, 'p').tanks = 1;
player(g, 'p').reserves = 19;
}
hold(g, 'q', 'shield');
return inner(g, 'p', {
type: 'card',
card,
...(effect === 'weather'
? { amount: 0 }
: effect === 'ghola'
? { amount: 1 }
: {}),
});
}
void test('all five pending frames expose only the public played card and make every AI profile wait', () => {
for (const effect of [
'hajr',
'weather',
'harvester',
'atomics',
'ghola',
] as const) {
const pending = simpleFrame(effect),
alternate = reload(pending),
idx = alternate.deck.findIndex((c) => c.kind === 'snooper');
[player(alternate, 'q').hand[0], alternate.deck[idx]] = [
alternate.deck[idx],
player(alternate, 'q').hand[0],
];
for (const id of ['p', 'q', 't']) {
const view = viewGame(pending, id);
assert.equal(view.automaticContinuationPending, true);
assert.ok(!('pendingTreacheryDiscard' in view));
assert.ok(!('treacheryDiscardSequence' in view));
assert.ok(view.log.some((e) => e.text.includes(' played ')));
if (id !== 'q') assert.deepEqual(viewGame(alternate, id), view);
for (const level of DIFFICULTIES) {
view.players.find((p) => p.id === id)!.bot = level;
assert.deepEqual(botActions(view), []);
}
}
const snapshot = structuredClone(pending),
bot = reload(pending);
player(bot, 'p').bot = 'Hard';
assert.doesNotThrow(() => normalizeAutomaticGame(bot));
assert.deepEqual(pending, snapshot);
assert.deepEqual(
reload({ ...runBots(reload(pending), 0), botsPending: undefined }),
reload({ ...recover(pending), botsPending: undefined }),
);
}
});
function rejectsCorrupt(frame: Game, mutations: ((g: Game) => void)[]) {
for (const [index, mutate] of mutations.entries()) {
const corrupt = reload(frame);
mutate(corrupt);
const snapshot = structuredClone(corrupt);
assert.throws(() => normalizeAutomaticGame(corrupt), `normalize ${index}`);
assert.throws(() => viewGame(corrupt, 'p'), `view ${index}`);
assert.throws(
() => applyAction(corrupt, 'p', { type: 'advanceBots' }),
`action ${index}`,
);
assert.deepEqual(corrupt, snapshot);
}
}
void test('every ordinary source rejects stale physical receipts, changed completed state and replay atomically', () => {
for (const effect of [
'hajr',
'weather',
'harvester',
'atomics',
'ghola',
] as const) {
const frame = simpleFrame(effect),
mutations: ((g: Game) => void)[] = [
(g) => {
g.turn++;
},
(g) => {
g.phase++;
},
(g) => {
g.status = 'setup';
},
(g) => {
g.status = 'finished';
},
(g) => {
g.treacheryDiscardSequence!++;
},
(g) => {
g.resolvedTreacheryDiscardSequence = g.treacheryDiscardSequence;
},
(g) => {
g.pendingTreacheryDiscard!.sequence++;
},
(g) => {
g.pendingTreacheryDiscard!.batch.event += 'stale';
},
(g) => {
g.pendingTreacheryDiscard!.batch.cause = 'ordinary:unknown';
},
(g) => {
g.pendingTreacheryDiscard!.batch.entries[0].publicFace = false;
},
(g) => {
g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = 't';
},
(g) => {
receipt(g).player = 'q';
},
(g) => {
receipt(g).card = 'missing';
},
(g) => {
Object.assign(receipt(g), { effect: 'unknown' });
},
(g) => {
receipt(g).stateSignature += 'stale';
},
(g) => {
g.discard = [];
},
(g) => {
g.deck.push(structuredClone(g.discard.at(-1)!));
},
(g) => {
player(g, 'q').hand.push(structuredClone(g.discard.at(-1)!));
},
(g) => {
g.discard.at(-1)!.effect = 'karama';
},
(g) => {
g.order.reverse();
},
(g) => {
g.ready.push('p');
},
(g) => {
player(g, 'p').spice++;
},
(g) => {
player(g, 'p').reserves++;
},
(g) => {
player(g, 'p').leaders[0].dead = true;
},
(g) => {
receipt(g).resume.phaseOpening = { passed: [], initialize: false };
},
(g) => {
receipt(g).resume.response = {
kind: 'revivalIncome',
owner: 'q',
recipient: 'p',
amount: 1,
passed: [],
};
},
];
for (const key of [
'response',
'decision',
'pendingKarama',
'phaseOpening',
'truthtrance',
'pendingNullentropy',
])
mutations.push((g) => {
Object.assign(g, { [key]: {} });
});
if (effect === 'hajr')
mutations.push(
(g) => {
g.hajr = [];
},
(g) => {
g.hajr.push('p');
},
(g) => {
g.active = 'q';
},
(g) => {
player(g, 'p').moved++;
},
);
if (effect === 'weather')
mutations.push(
(g) => {
g.stormPending = null;
},
(g) => {
g.stormPending = 1;
},
(g) => {
g.storm++;
},
);
if (effect === 'harvester')
mutations.push(
(g) => {
g.spiceWindow!.amount++;
},
(g) => {
g.spiceWindow!.harvesters = 0;
},
(g) => {
g.spiceWindow!.territory = 'hagga_basin';
},
(g) => {
g.spice['red_chasm:7']++;
},
);
if (effect === 'atomics')
mutations.push(
(g) => {
g.shieldWallDestroyed = false;
},
(g) => {
player(g, 'p').forces['shield_wall:8'] = 1;
},
(g) => {
player(g, 'p').tanks = 0;
},
);
if (effect === 'ghola')
mutations.push(
(g) => {
player(g, 'p').tanks = 1;
},
(g) => {
player(g, 'p').revived++;
},
);
rejectsCorrupt(frame, mutations);
const done = recover(frame);
done.pendingTreacheryDiscard = structuredClone(
frame.pendingTreacheryDiscard,
);
rejectsCorrupt(done, [() => {}]);
}
});
void test('Ghola income and accrued technology cannot be rewritten or paid before the frame retires', () => {
const g = fixture(4, true, ['emperor', 'harkonnen', 'tleilaxu']),
card = hold(g, 'p', 'ghola');
hold(g, 'q', 'karama');
player(g, 'p').tanks = 1;
player(g, 'p').reserves = 19;
g.techTokens = createTechTokens(g.players);
const frame = inner(g, 'p', { type: 'card', card, amount: 1 });
rejectsCorrupt(frame, [
(x) => {
receipt(x).resume.response = null;
},
(x) => {
receipt(x).resume.response!.owner = 'q';
},
(x) => {
receipt(x).resume.response!.recipient = 'q';
},
(x) => {
receipt(x).resume.response!.amount = 2;
},
(x) => {
receipt(x).resume.response!.passed = ['q'];
},
(x) => {
x.techTokens!.axlotl.spice++;
},
(x) => {
x.techTokens!.axlotl.owner = 'q';
},
(x) => {
x.techTokens!.axlotl.triggeredTurn = 1;
},
(x) => {
player(x, 't').spice++;
},
(x) => {
x.revivalFreeIncome = { p: x.turn };
},
]);
});
void test('untrusted action JSON cannot select the internal shipment-preparation execution mode', () => {
const g = fixture(4),
card = hold(g, 'p', 'ghola');
player(g, 'p').tanks = 1;
player(g, 'p').reserves = 19;
const frame = inner(g, 'p', {
type: 'card',
card,
amount: 1,
execution: 'shipmentPreparation',
mode: 'shipmentPreparation',
});
assert.equal(receipt(frame).effect, 'ghola');
assert.equal(player(frame, 'p').tanks, 0);
assert.equal(recover(frame).resolvedTreacheryDiscardSequence, 1);
});
void test('a dead-leader preparation witness stays private and its Ghola cannot be diverted after a binding answer', () => {
let g = fixture(6);
player(g, 'p').forces = { 'arrakeen:10': 4 };
player(g, 'p').reserves = 15;
player(g, 'p').tanks = 1;
player(g, 't').forces = { 'arrakeen:10': 4 };
player(g, 't').reserves = 16;
player(g, 'p').leaders[0].dead = true;
player(g, 'p').leaders[0].deaths = 1;
const card = hold(g, 'p', 'ghola'),
leader = player(g, 'p').leaders[0].id;
g.active = 'p';
g = allow(
applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 't',
}),
);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
g = ask(g, {
kind: 'battlePlan',
target: 'p',
territory: 'arrakeen',
claim: { kind: 'leader', leader },
});
g = applyAction(g, 'p', { type: 'truthAnswer', answer: 'yes' });
const snapshot = structuredClone(g),
prep = viewGame(g, 'p').battle!.compliantPreparation!;
assert.equal(prep.actions[0].leader, leader);
assert.equal(viewGame(g, 't').battle!.compliantPreparation, null);
assert.deepEqual(g, snapshot);
assert.throws(
() => applyAction(g, 'p', { type: 'card', card, amount: 1 }),
/voluntar/,
);
assert.deepEqual(g, snapshot);
const { pending, done } = play(g, 'p', prep.actions[0]);
assert.equal(player(pending, 'p').leaders[0].dead, false);
assert.equal(done.battle!.truthPromises![0].released, undefined);
const plan = viewGame(done, 'p').battle!.compliantPlan!;
assert.equal(plan.leader, leader);
assert.equal(
applyAction(done, 'p', { type: 'battlePlan', ...plan }).battle!.plans.p
.leader,
leader,
);
});
void test('a saved Ecaz Duke Ghola return binds one unclaimed shared record outside native arrays', () => {
const g = fixture(4, true, ['ecaz', 'emperor', 'atreides']),
card = hold(g, 'p', 'ghola');
// A legacy retained controller is intentionally present: revival must
// release it, even when a consumed-card save interrupts disposal.
g.dukeVidal = acquireDuke(createDukeVidal(), 'p', g.turn, 'ecaz');
g.dukeVidal.leader.dead = true;
g.dukeVidal.leader.deaths = 1;
const { pending, done } = play(g, 'p', {
type: 'card',
card,
leader: DUKE_VIDAL_ID,
});
assert.equal(pending.dukeVidal!.leader.dead, false);
assert.equal(pending.dukeVidal!.controller, null);
assert.equal(pending.dukeVidal!.source, null);
assert.equal(pending.dukeVidal!.acquiredTurn, null);
assert.deepEqual(done.dukeVidal, pending.dukeVidal);
assert.equal(
done.players.some((p) => p.leaders.some((l) => l.id === DUKE_VIDAL_ID)),
false,
);
rejectsCorrupt(pending, [
(x) => {
x.dukeVidal!.leader.dead = true;
},
(x) => {
x.dukeVidal!.leader.deaths++;
},
(x) => {
x.dukeVidal!.controller = 'q';
},
(x) => {
x.dukeVidal!.leader.capturedBy = 'q';
},
(x) => {
x.dukeVidal!.leader.gholaBy = 'q';
},
(x) => {
delete x.dukeVidal;
},
]);
});