1078 lines
34 KiB
TypeScript
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;
|
|
},
|
|
]);
|
|
});
|