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

760 lines
25 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, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { createTechTokens } from '../game/tech-tokens';
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;
}
function fixture(winner: FactionId = 'guild', extra?: 'moritani' | 'choam') {
const g = createGame('BATTLEFRAME', newPlayer('w', 'Winner', winner), true);
g.players.push(newPlayer('l', 'Loser', 'emperor'));
if (extra) g.players.push(newPlayer('x', 'Observer', extra));
Object.assign(g, {
status: 'playing',
phase: 6,
turn: 2,
storm: 18,
active: 'w',
order: g.players.map((p) => p.id),
deck: baseDeck(),
});
for (const p of g.players) {
p.forces = p.id === 'x' ? {} : { 'arrakeen:10': 5 };
p.reserves = p.id === 'x' ? 20 : 15;
p.spice = 20;
p.hand = [];
p.traitors = [];
for (const leader of p.leaders) leader.strength = 0;
}
if (extra === 'moritani') {
player(g, 'l').ally = 'x';
player(g, 'x').ally = 'l';
}
return g;
}
function hold(g: Game, id: string, kind: Card['kind']) {
const index = g.deck.findIndex((c) => c.kind === kind);
assert.ok(index >= 0);
const [card] = g.deck.splice(index, 1);
player(g, id).hand.push(card);
return card.id;
}
function inventory(g: Game) {
const ids = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort();
assert.equal(
new Set(ids).size,
ids.length,
'every physical card has one custodian',
);
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
return ids;
}
function prepared(state: Game) {
let g = applyAction(state, 'w', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'l',
});
for (let i = 0; i < 40; i++) {
if (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
else if (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
else return g;
}
throw Error('Preparation stalled');
}
function beforeLast(
state: Game,
options: {
hero?: string;
weapon?: string;
defense?: string;
loserWeapon?: string;
loserDefense?: string;
dial?: number;
support?: number;
traitors?: boolean;
} = {},
) {
let g = prepared(state);
g = applyAction(g, 'w', {
type: 'battlePlan',
dial: options.dial ?? 2,
support: options.support ?? 2,
leader: options.hero ?? player(g, 'w').leaders[0].id,
weapon: options.weapon,
defense: options.defense,
});
g = applyAction(g, 'l', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: player(g, 'l').leaders[0].id,
weapon: options.loserWeapon,
defense: options.loserDefense,
});
return applyAction(g, 'w', {
type: 'traitorCall',
call: options.traitors ?? false,
});
}
const finalAction = (call = false): Action => ({ type: 'traitorCall', call });
function frame(g: Game, call = false) {
const result = inner(g, 'l', finalAction(call));
assert.equal(
result.pendingTreacheryDiscard?.continuation.kind,
'battleResolved',
);
return result;
}
function mandatory(g: Game) {
const c = g.pendingTreacheryDiscard!.continuation;
assert.equal(c.kind, 'battleResolved');
if (c.kind !== 'battleResolved') throw Error('Wrong continuation');
return c;
}
function stable(g: Game) {
return g.players.map((p) => ({
id: p.id,
spice: p.spice,
tanks: p.tanks,
leaders: p.leaders,
}));
}
function resumed(g: Game) {
const result = normalizeAutomaticGame(reload(g));
assert.equal(result.pendingTreacheryDiscard, null);
assert.deepEqual(normalizeAutomaticGame(reload(result)), result);
return result;
}
void test('last real traitor vote stages a mixed-owner public discard after double-traitor losses, with no replay', () => {
const initial = fixture();
const w = hold(initial, 'w', 'shield'),
l = hold(initial, 'l', 'snooper');
player(initial, 'w').traitors = [player(initial, 'l').leaders[0].id];
player(initial, 'l').traitors = [player(initial, 'w').leaders[0].id];
const ids = inventory(initial);
const before = beforeLast(initial, {
defense: w,
loserDefense: l,
traitors: true,
});
for (const p of before.players) {
const revealed = viewGame(before, p.id).battle!;
assert.equal(revealed.revealed, true);
assert.deepEqual(
revealed.cards.map((c) => c.id),
[w, l],
);
}
const pending = frame(before, true),
batch = pending.pendingTreacheryDiscard!.batch;
assert.deepEqual(
batch.entries.map((e) => [e.card.id, e.discardedBy, e.publicFace]),
[
[w, 'w', true],
[l, 'l', true],
],
);
assert.equal(batch.cause, 'battle:mandatory');
assert.equal(mandatory(pending).winner, null);
assert.equal(pending.battle, null);
assert.deepEqual(pending.lastBattle, ['w', 'l']);
assert.deepEqual(
pending.players.map((p) => p.tanks),
[5, 5],
);
assert.deepEqual(
pending.players.map((p) => p.leaders[0].deaths),
[1, 1],
);
const result = resumed(pending);
assert.deepEqual(stable(result), stable(pending));
assert.deepEqual(result, applyAction(before, 'l', finalAction(true)));
assert.deepEqual(inventory(result), ids);
assert.equal(result.resolvedTreacheryDiscardSequence, 1);
});
void test('winner hero and loser defense share one mandatory batch before the sole supported casualty allocation', () => {
const initial = fixture();
const hero = hold(initial, 'w', 'hero'),
defense = hold(initial, 'l', 'snooper');
const ids = inventory(initial),
before = beforeLast(initial, { hero, loserDefense: defense });
const pending = frame(before),
c = mandatory(pending);
assert.equal(c.casualties!.options.length, 1);
assert.deepEqual(
pending.pendingTreacheryDiscard!.batch.entries.map((e) => e.discardedBy),
['w', 'l'],
);
assert.equal(player(pending, 'w').tanks, 0);
assert.equal(player(pending, 'l').tanks, 5);
assert.equal(player(pending, 'w').spice, 18);
const result = resumed(pending);
assert.equal(player(result, 'w').tanks, 2);
assert.equal(
result.log.filter((e) => e.automatic?.name === 'Battle casualties').length,
1,
);
assert.deepEqual(result, applyAction(before, 'l', finalAction()));
assert.deepEqual(inventory(result), ids);
});
void test('Fedaykin casualty choices survive reload with their committed effective force allocation', () => {
const initial = fixture('fremen');
player(initial, 'w').elites = {
reserves: 2,
tanks: 0,
forces: { 'arrakeen:10': 1 },
revived: 0,
};
const defense = hold(initial, 'l', 'snooper');
const pending = frame(
beforeLast(initial, { loserDefense: defense, support: 0 }),
);
const options = mandatory(pending).casualties!.options;
assert.ok(options.some((c) => c.normal === 2 && c.elite === 0));
assert.ok(options.some((c) => c.normal === 0 && c.elite === 1));
const result = resumed(pending);
assert.equal(result.decision?.kind, 'battleLosses');
assert.equal(player(result, 'w').tanks, 0);
for (let choice = 0; choice < options.length; choice++) {
const chosen = applyAction(reload(result), 'w', {
type: 'decision',
choice,
});
assert.equal(
player(chosen, 'w').tanks,
options[choice].normal + options[choice].elite,
);
assert.equal(player(chosen, 'w').elites!.tanks, options[choice].elite);
inventory(chosen);
}
});
void test('leader bounty and CHOAM support income occur once across mandatory and later winner discard frames', () => {
const initial = fixture('guild', 'choam');
player(initial, 'l').leaders[0].strength = 3;
const weapon = hold(initial, 'w', 'projectile'),
defense = hold(initial, 'l', 'snooper');
const pending = frame(beforeLast(initial, { weapon, loserDefense: defense }));
assert.equal(player(pending, 'w').spice, 21);
assert.equal(player(pending, 'l').leaders[0].deaths, 1);
assert.equal(player(pending, 'x').spice, 20);
let result = resumed(pending);
assert.equal(result.decision?.kind, 'battleCards');
const cleanup = inner(result, 'w', { type: 'decision', discard: [weapon] });
assert.equal(cleanup.pendingTreacheryDiscard?.batch.cause, 'battle:winner');
assert.equal(cleanup.pendingTreacheryDiscard?.sequence, 2);
assert.deepEqual(
cleanup.pendingTreacheryDiscard?.batch.entries.map((e) => e.card.id),
[weapon],
);
result = resumed(cleanup);
assert.equal(player(result, 'x').spice, 21);
assert.equal(player(result, 'l').leaders[0].deaths, 1);
assert.equal(player(result, 'w').tanks, 2);
assert.equal(result.resolvedTreacheryDiscardSequence, 2);
inventory(result);
});
void test('Moritani reserves loser cards from the mandatory batch and disposes them only in its later cleanup', () => {
const initial = fixture('guild', 'moritani');
const hero = hold(initial, 'w', 'hero'),
weapon = hold(initial, 'l', 'projectile'),
defense = hold(initial, 'l', 'snooper');
const pending = frame(
beforeLast(initial, { hero, loserWeapon: weapon, loserDefense: defense }),
);
assert.deepEqual(
pending.pendingTreacheryDiscard!.batch.entries.map((e) => e.card.id),
[hero],
);
assert.deepEqual(pending.moritaniRetention!.played, [weapon, defense]);
const result = resumed(pending);
assert.equal(result.decision?.kind, 'moritaniRetention');
const next = inner(result, 'l', { type: 'decision', keep: null });
assert.equal(next.pendingTreacheryDiscard?.batch.cause, 'battle:moritani');
assert.deepEqual(
next.pendingTreacheryDiscard?.batch.entries.map((e) => e.card.id),
[weapon, defense],
);
assert.equal(next.moritaniRetention, null);
const settled = resumed(next);
assert.equal(settled.resolvedTreacheryDiscardSequence, 2);
assert.equal(player(settled, 'l').hand.length, 0);
inventory(settled);
});
void test('an empty mandatory batch goes directly to cleanup without allocating a discard event', () => {
const initial = fixture(),
before = beforeLast(initial);
const result = inner(before, 'l', finalAction());
assert.equal(result.pendingTreacheryDiscard ?? null, null);
assert.equal(result.treacheryDiscardSequence ?? 0, 0);
assert.equal(player(result, 'w').tanks, 2);
assert.deepEqual(
normalizeAutomaticGame(result),
applyAction(before, 'l', finalAction()),
);
});
void test('winner and Moritani cleanup validate the event, complete played set, retained custody and consumed alliance record', () => {
for (const source of ['winner', 'moritani'] as const) {
const initial = fixture(
'guild',
source === 'moritani' ? 'moritani' : undefined,
);
const weapon = hold(initial, 'w', 'projectile'),
defense = hold(initial, 'w', 'shield');
const loserDefense = hold(initial, 'l', 'snooper');
let g = applyAction(
beforeLast(initial, { weapon, defense, loserDefense }),
'l',
finalAction(),
);
if (source === 'moritani')
g = applyAction(g, 'w', { type: 'decision', discard: [] });
const pending = inner(
g,
source === 'winner' ? 'w' : 'l',
source === 'winner'
? { type: 'decision', discard: [weapon] }
: { type: 'decision', keep: null },
);
const continuation = (state: Game) => {
const c = state.pendingTreacheryDiscard!.continuation;
if (c.kind !== 'battleCleanup') throw Error('Missing cleanup');
return c;
};
assert.equal(continuation(pending).source, source);
const changes: ((g: Game) => void)[] = [
(g) => {
continuation(g).event = 'stale';
},
(g) => {
continuation(g).played = [];
},
(g) => {
continuation(g).played.push(continuation(g).played[0]);
},
(g) => {
continuation(g).player = 'stranger';
},
(g) => {
continuation(g).source = source === 'winner' ? 'moritani' : 'winner';
},
(g) => {
g.lastBattleContext!.territory = 'carthag';
},
];
if (source === 'winner')
changes.push(
(g) => {
g.deck.push(
structuredClone(player(g, 'w').hand.find((c) => c.id === defense)!),
);
},
(g) => {
continuation(g).kept = [];
},
);
else
changes.push(
(g) => {
delete continuation(g).retention;
},
(g) => {
player(g, 'x').ally = null;
},
(g) => {
continuation(g).retention!.turn--;
},
(g) => {
continuation(g).retention!.played = [];
},
);
for (const change of changes) {
const corrupt = reload(pending);
change(corrupt);
const before = structuredClone(corrupt);
assert.throws(() => normalizeAutomaticGame(corrupt));
assert.throws(() => viewGame(corrupt, 'w'));
assert.throws(() => applyAction(corrupt, 'w', { type: 'advanceBots' }));
assert.deepEqual(corrupt, before);
}
resumed(pending);
inventory(pending);
}
});
void test('battle frames reject stale identity, private faces, custody corruption and inconsistent frozen casualties atomically', () => {
const initial = fixture(),
defense = hold(initial, 'l', 'snooper');
const valid = frame(beforeLast(initial, { loserDefense: defense }));
const corruptions: ((g: Game) => void)[] = [
(g) => {
g.turn++;
},
(g) => {
g.phase = 7;
},
(g) => {
g.treacheryDiscardSequence!++;
},
(g) => {
g.lastBattle = ['l', 'w'];
},
(g) => {
mandatory(g).combatants = ['w', 'w'];
},
(g) => {
mandatory(g).territory = 'missing';
},
(g) => {
mandatory(g).winner = 'stranger';
},
(g) => {
g.pendingTreacheryDiscard!.batch.entries[0].publicFace = false;
},
(g) => {
g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = 'stranger';
},
(g) => {
g.deck.push(structuredClone(g.discard[0]));
},
(g) => {
g.discard = [];
},
(g) => {
mandatory(g).cards = [defense];
},
(g) => {
mandatory(g).casualties!.forces.normal++;
},
(g) => {
mandatory(g).casualties!.options[0].normal++;
},
(g) => {
mandatory(g).casualties!.dial = -1;
},
(g) => {
g.pendingTreacheryDiscard!.batch.cause = 'battle:winner';
},
(g) => {
delete mandatory(g).casualties;
},
(g) => {
mandatory(g).event = 'stale-battle';
},
(g) => {
mandatory(g).result = 'traitor';
},
(g) => {
g.lastBattleContext!.winner = 'l';
},
(g) => {
g.lastBattleContext!.turn--;
},
];
for (const corrupt of corruptions) {
const g = reload(valid);
corrupt(g);
const before = structuredClone(g);
assert.throws(() => normalizeAutomaticGame(g));
assert.throws(() => applyAction(g, 'w', { type: 'advanceBots' }));
assert.throws(() => viewGame(g, 'w'));
assert.deepEqual(g, before);
}
});
void test('all profiles wait on the public automatic marker; battle faces are public but unrelated hands remain private', () => {
const initial = fixture(),
defense = hold(initial, 'l', 'snooper');
const secret = hold(initial, 'w', 'worthless');
const pending = frame(beforeLast(initial, { loserDefense: defense }));
for (const difficulty of DIFFICULTIES)
for (const p of pending.players) {
const view = viewGame(pending, p.id);
view.players.find((other) => other.id === p.id)!.bot = difficulty;
assert.equal(view.automaticContinuationPending, true);
assert.deepEqual(botActions(view), []);
assert.ok(!('pendingTreacheryDiscard' in view));
const opponent = view.players.find((other) => other.id !== p.id)!;
assert.equal(opponent.hand, undefined);
if (p.id === 'l') assert.ok(!JSON.stringify(view).includes(secret));
}
const before = structuredClone(pending),
automatic = runBots(reload(pending), 0);
assert.equal(automatic.pendingTreacheryDiscard, null);
assert.deepEqual(pending, before);
assert.deepEqual(
{ ...automatic, botsPending: undefined },
{ ...resumed(pending), botsPending: undefined },
);
});
for (const blockElite of [false, true])
for (const blockSupport of [false, true])
void test(`persisted Fremen casualties preserve actual Karama effects: elite ${blockElite ? 'canceled' : 'allowed'}, free support ${blockSupport ? 'canceled' : 'allowed'}`, () => {
let g = fixture('fremen', 'choam');
player(g, 'w').elites = {
reserves: 2,
tanks: 0,
forces: { 'arrakeen:10': 1 },
revived: 0,
};
const defense = hold(g, 'l', 'snooper');
const karamas = g.deck.filter((c) => c.effect === 'karama');
for (const card of karamas) {
g.deck.splice(
g.deck.findIndex((c) => c.id === card.id),
1,
);
player(g, 'x').hand.push(card);
}
const ids = inventory(g);
g = applyAction(g, 'w', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'l',
});
for (let i = 0; g.response && i < 30; i++) {
const cancel =
(g.response.kind === 'eliteStrength' && blockElite) ||
(g.response.kind === 'fremenSupport' && blockSupport);
if (cancel)
g = applyAction(g, 'x', {
type: 'card',
card: player(g, 'x').hand.find((c) => c.effect === 'karama')!.id,
mode: 'cancel',
});
else
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
}
assert.equal(g.response, null);
assert.equal(
g.discard.filter((c) => c.effect === 'karama').length,
Number(blockElite) + Number(blockSupport),
);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
g = applyAction(g, 'w', {
type: 'battlePlan',
leader: player(g, 'w').leaders[0].id,
dial: 2,
support: blockSupport ? 2 : 0,
});
g = applyAction(g, 'l', {
type: 'battlePlan',
leader: player(g, 'l').leaders[0].id,
dial: 0,
support: 0,
defense,
});
g = applyAction(g, 'w', finalAction());
const pending = frame(g),
commitment = mandatory(pending).casualties!;
assert.equal(pending.battle, null);
assert.equal(commitment.forces.freeSupport, !blockSupport);
assert.equal(commitment.forces.eliteStrength, blockElite ? 1 : 2);
assert.equal(commitment.forces.normal, 4);
assert.equal(commitment.forces.elite, 1);
assert.equal(player(pending, 'w').tanks, 0);
const recovered = resumed(pending);
assert.deepEqual(recovered, applyAction(g, 'l', finalAction()));
if (commitment.options.length === 1) {
assert.equal(player(recovered, 'w').tanks, 2);
assert.equal(player(recovered, 'w').elites!.tanks, 0);
assert.notEqual(recovered.decision?.kind, 'battleLosses');
} else {
assert.equal(recovered.decision?.kind, 'battleLosses');
assert.equal(player(recovered, 'w').tanks, 0);
if (recovered.decision?.kind !== 'battleLosses')
throw Error('Missing casualty decision');
assert.deepEqual(recovered.decision.options, commitment.options);
}
for (const p of recovered.players) viewGame(recovered, p.id);
assert.deepEqual(inventory(recovered), ids);
});
for (const winner of ['harkonnen', 'choam'] as const)
void test(`${winner} simultaneous post-battle effects survive recovery and reject inconsistent saved obligations`, () => {
const g = fixture(winner, winner === 'harkonnen' ? 'choam' : undefined);
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
g.order.push('t');
if (winner === 'choam') player(g, 'w').leaders.push(createAuditorLeader());
g.techTokens = createTechTokens();
g.techTokens.axlotl.owner = 'l';
g.techTokens.production.owner = 'l';
const defense = hold(g, 'l', 'snooper');
hold(g, 'l', 'worthless');
const ids = inventory(g);
const before = beforeLast(g, {
loserDefense: defense,
...(winner === 'choam' ? { hero: CHOAM_AUDITOR_ID } : {}),
});
const pending = frame(before);
assert.ok(pending.pendingFaceDance);
assert.ok(pending.pendingTech);
if (winner === 'harkonnen') {
assert.ok(pending.pendingCapture);
assert.ok(pending.pendingChoamBattleIncome);
} else assert.ok(pending.pendingAuditor);
const result = resumed(pending);
assert.deepEqual(result, applyAction(before, 'l', finalAction()));
assert.equal(result.decision?.kind, 'techToken');
assert.deepEqual(result.pendingFaceDance, pending.pendingFaceDance);
assert.deepEqual(result.pendingCapture, pending.pendingCapture);
assert.deepEqual(result.pendingAuditor, pending.pendingAuditor);
if (winner === 'harkonnen') {
assert.equal(player(result, 'x').spice, 21);
assert.equal(result.pendingChoamBattleIncome, null);
}
for (const p of result.players) viewGame(result, p.id);
assert.deepEqual(inventory(result), ids);
const mutations: ((g: Game) => void)[] = [
(g) => {
g.pendingFaceDance!.winner = 'l';
},
(g) => {
g.pendingFaceDance!.territory = 'carthag';
},
(g) => {
g.pendingFaceDance!.player = 'l';
},
(g) => {
g.pendingTech!.player = 'l';
},
(g) => {
g.pendingTech!.loser = 'w';
},
(g) => {
g.pendingTech!.choices = [];
},
(g) => {
g.pendingTech!.choices.push(g.pendingTech!.choices[0]);
},
(g) => {
g.techTokens!.axlotl.owner = 't';
},
];
if (winner === 'harkonnen')
mutations.push(
(g) => {
g.pendingCapture!.player = 'l';
},
(g) => {
g.pendingCapture!.loser = 'w';
},
(g) => {
g.pendingCapture!.territory = 'carthag';
},
(g) => {
g.pendingChoamBattleIncome!.amount = 0;
},
(g) => {
g.pendingChoamBattleIncome!.amount = 0.5;
},
(g) => {
g.pendingChoamBattleIncome!.owner = 'w';
},
);
else
mutations.push(
(g) => {
g.pendingAuditor!.event = 'stale';
},
(g) => {
g.pendingAuditor!.territory = 'carthag';
},
(g) => {
g.pendingAuditor!.stage = 'response';
},
(g) => {
g.pendingAuditor!.owner = 'l';
},
);
for (const mutate of mutations) {
const corrupt = reload(pending);
mutate(corrupt);
const unchanged = structuredClone(corrupt);
assert.throws(() => normalizeAutomaticGame(corrupt));
assert.throws(() => applyAction(corrupt, 'w', { type: 'advanceBots' }));
assert.throws(() => viewGame(corrupt, 'w'));
assert.deepEqual(corrupt, unchanged);
}
});