Files
Dune/tests/homeworld-victory-reinforcement-engine.test.ts
T

462 lines
17 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,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { caladanReinforcementActions } from '../game/caladan-reinforcement-options';
import { homeworldVictoryOfferSignature } from '../game/homeworld-victory-return';
import {
caladanVictoryFixture,
prepareVictoryBattle,
commitVictoryPlans,
finishVictoryCalls,
positionVictoryArmy,
positionVictoryInvader,
holdVictoryCard,
victoryInventory as inventory,
victoryPlayer as p,
victoryReload as reload,
} from './fixture-caladan-victory';
function victory(g = caladanVictoryFixture()) {
return finishVictoryCalls(commitVictoryPlans(prepareVictoryBattle(g)));
}
function reinforce(g: Game, destination = 'hagga_basin:12'): Action {
const offer = viewGame(g, 'a').caladanReinforcement;
assert.ok(offer);
assert.ok(
offer.destinations.some(
(target) => target.id === destination && !target.blocked,
),
);
return { type: 'decision', event: offer.event, destination, amount: 1 };
}
function reject(g: Game, actor: string, action: Action, match?: RegExp) {
const before = structuredClone(g);
if (match) assert.throws(() => applyAction(g, actor, action), match);
else assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
function stable(g: Game) {
inventory(g);
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
for (const player of g.players) {
const view = viewGame(reload(g), player.id);
for (const other of view.players.filter(
(other) => other.id !== player.id,
)) {
assert.equal(other.hand, undefined);
assert.equal(other.spice, undefined);
}
}
}
void test('real Basic and Advanced victories offer one force after casualties, take Caladan six to five, and settle once', () => {
for (const advanced of [false, true]) {
const g = victory(caladanVictoryFixture({ advanced }));
assert.equal(g.phase, 6);
assert.equal(g.decision?.kind, 'caladanReinforcement');
assert.equal(
g.homeworldVictoryReinforcement!.event,
g.lastBattleContext!.event,
);
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, false);
assert.equal(p(g, 'a').forces['hagga_basin:12'], 2);
assert.equal(p(g, 'a').tanks, 1);
assert.equal(p(g, 'g').tanks, 3);
stable(g);
const before = structuredClone(p(g, 'a'));
const action = reinforce(g);
for (const amount of [0, 2])
reject(g, 'a', { ...action, amount }, /exactly one/);
reject(g, 'a', { ...action, destination: 'arrakeen:10' });
reject(g, 'g', action);
const done = applyAction(reload(g), 'a', action);
assert.equal(p(done, 'a').reserves, 5);
assert.equal(p(done, 'a').forces['hagga_basin:12'], 3);
assert.equal(p(done, 'a').tanks, before.tanks);
assert.equal(p(done, 'a').spice, before.spice);
assert.equal(p(done, 'a').battleLosses, before.battleLosses);
assert.equal(p(done, 'a').shipped, before.shipped);
assert.equal(p(done, 'a').moved, before.moved);
assert.equal(done.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(done.lastBattleContext!.caladanReinforcement!.completed, true);
assert.equal(done.phase, 7);
reject(done, 'a', action);
stable(done);
}
});
void test('low Caladan or an eliminated winning army closes the opportunity without reviving a force', () => {
for (const advanced of [false, true])
for (const depleted of [false, true]) {
const setup = caladanVictoryFixture({
advanced,
native: depleted ? 6 : 5,
});
let g = prepareVictoryBattle(setup);
g = commitVictoryPlans(g, {
a: {
dial: depleted ? 3 : 1,
support: advanced ? (depleted ? 3 : 1) : 0,
},
});
g = finishVictoryCalls(g);
assert.equal(g.lastBattleContext!.winner, 'a');
assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(g.homeworldVictoryReinforcement!.destination, 'decline');
assert.equal(viewGame(g, 'a').caladanReinforcement, null);
assert.equal(p(g, 'a').reserves, depleted ? 6 : 5);
assert.equal(p(g, 'a').tanks, depleted ? 3 : 1);
stable(g);
}
});
void test('an Atreides traitor victory qualifies; losing, mutual traitors and explosion never create a Caladan grant', () => {
for (const outcome of [
'traitorWin',
'traitorLoss',
'mutual',
'loss',
'explosion',
] as const) {
let g = caladanVictoryFixture();
const aLeader = p(g, 'a').leaders.reduce((best, leader) =>
leader.strength > best.strength ? leader : best,
).id;
const gLeader = p(g, 'g').leaders.reduce((best, leader) =>
leader.strength < best.strength ? leader : best,
).id;
// Explicit sealed-traitor outcome seam; battle actions still reveal their
// actual matching leader IDs. Treachery and physical forces remain unique.
if (outcome === 'traitorWin' || outcome === 'mutual')
p(g, 'a').traitors = [gLeader];
if (outcome === 'traitorLoss' || outcome === 'mutual')
p(g, 'g').traitors = [aLeader];
const weapon =
outcome === 'explosion' ? holdVictoryCard(g, 'a', 'lasgun') : undefined;
const defense =
outcome === 'explosion' ? holdVictoryCard(g, 'g', 'shield') : undefined;
g = prepareVictoryBattle(g);
g = commitVictoryPlans(g, {
a:
outcome === 'loss'
? {
leader: p(g, 'a').leaders.find((leader) => leader.strength === 1)!
.id,
dial: 0,
}
: { weapon },
g: outcome === 'loss' ? { dial: 3 } : { defense },
});
g = finishVictoryCalls(g, {
a: outcome === 'traitorWin' || outcome === 'mutual',
g: outcome === 'traitorLoss' || outcome === 'mutual',
});
if (outcome === 'traitorWin') {
assert.equal(g.lastBattleContext!.result, 'traitor');
assert.equal(p(g, 'a').tanks, 0);
assert.equal(g.decision?.kind, 'caladanReinforcement');
const returned = structuredClone(p(g, 'a'));
g = applyAction(g, 'a', {
type: 'decision',
event: g.homeworldVictoryReinforcement!.event,
decline: true,
});
assert.deepEqual(p(g, 'a'), returned);
} else {
assert.equal(g.homeworldVictoryReinforcement ?? null, null);
assert.equal(g.lastBattleContext!.caladanReinforcement, undefined);
assert.notEqual(g.lastBattleContext!.winner, 'a');
}
stable(g);
}
});
void test('native Caladan defense is a physical no-op while a surviving foreign Homeworld army receives one real reserve', () => {
for (const native of [true, false]) {
let g = caladanVictoryFixture();
positionVictoryArmy(g, 'a', native ? 6 : 9, 0);
positionVictoryArmy(g, 'g', native ? 17 : 3, 0);
const destination = native ? 'homeworld:atreides' : 'homeworld:guild';
positionVictoryInvader(g, native ? 'g' : 'a', destination, native ? 2 : 3);
if (native) {
g.order = ['g', 'a'];
g.active = 'g';
}
g = prepareVictoryBattle(
g,
destination,
native ? 'g' : 'a',
native ? 'a' : 'g',
);
g = finishVictoryCalls(
commitVictoryPlans(g, { a: { dial: native ? 0 : 1, support: 0 } }),
);
assert.equal(g.lastBattleContext!.winner, 'a');
if (native) {
assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(viewGame(g, 'a').caladanReinforcement, null);
assert.equal(p(g, 'a').reserves, 6);
assert.equal(p(g, 'a').tanks, 0);
} else {
assert.equal(g.homeworlds!.custody!.visitors[destination].a.normal, 2);
g = applyAction(reload(g), 'a', reinforce(g, destination));
assert.equal(g.homeworlds!.custody!.visitors[destination].a.normal, 3);
assert.equal(p(g, 'a').reserves, 5);
assert.equal(p(g, 'a').tanks, 1);
}
stable(g);
}
});
void test('losing and mandatory winning discards and optional card cleanup all precede the reinforcement choice', () => {
// Observe the unchanged real dispatcher just before its public wrapper drains
// automatic discards, matching existing battle recovery observation seams.
const observed: {
applyActionInner?: (g: Game, id: string, action: 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,
},
);
let g = caladanVictoryFixture({ advanced: true });
const hero = holdVictoryCard(g, 'a', 'hero');
const shield = holdVictoryCard(g, 'a', 'shield');
const weapon = holdVictoryCard(g, 'g', 'projectile');
g = commitVictoryPlans(prepareVictoryBattle(g), {
a: { leader: hero, defense: shield, dial: 2, support: 2 },
g: { weapon },
});
g = applyAction(g, 'a', { type: 'traitorCall', call: false });
const before = structuredClone(g);
const suspended = reload(
observed.applyActionInner!(g, 'g', { type: 'traitorCall', call: false }),
);
assert.deepEqual(g, before);
assert.equal(
suspended.pendingTreacheryDiscard!.continuation.kind,
'battleResolved',
);
assert.equal(suspended.homeworldVictoryReinforcement!.stage, 'waiting');
assert.equal(viewGame(suspended, 'a').caladanReinforcement, null);
assert.deepEqual(suspended.pendingWinnerDiscards!.cards, [hero]);
assert.equal(p(suspended, 'a').tanks, 0);
const firstSequence = suspended.treacheryDiscardSequence!;
g = normalizeAutomaticGame(suspended);
assert.equal(g.decision?.kind, 'battleCards');
assert.equal(g.homeworldVictoryReinforcement!.stage, 'waiting');
assert.equal(p(g, 'a').tanks, 2);
assert.equal(
g.discard.filter((card) => card.id === hero || card.id === weapon).length,
2,
);
assert.equal(g.resolvedTreacheryDiscardSequence, firstSequence + 1);
g = applyAction(reload(g), 'a', { type: 'decision', discard: [shield] });
assert.equal(g.decision?.kind, 'caladanReinforcement');
assert.equal(g.resolvedTreacheryDiscardSequence, firstSequence + 2);
assert.equal(
g.discard.filter((card) => [hero, weapon, shield].includes(card.id)).length,
3,
);
assert.equal(p(g, 'a').forces['hagga_basin:12'], 1);
stable(g);
const done = applyAction(g, 'a', reinforce(g));
assert.equal(p(done, 'a').forces['hagga_basin:12'], 2);
assert.equal(p(done, 'a').tanks, 2);
stable(done);
});
void test('Caladan saved event and independent obligation reject stale actions, edited facts and deleted frame/choice before mutation', () => {
const g = victory();
const action = reinforce(g);
reject(g, 'a', { ...action, event: 'old-battle' });
reject(g, 'a', { ...action, decline: true });
for (const mutate of [
(bad: Game) => {
bad.homeworldVictoryReinforcement!.player = 'g';
},
(bad: Game) => {
bad.homeworldVictoryReinforcement!.territory = 'arrakeen';
},
(bad: Game) => {
bad.homeworldVictoryReinforcement!.offer!.survivors++;
},
(bad: Game) => {
bad.homeworldVictoryReinforcement!.signature = 'forged';
},
(bad: Game) => {
delete bad.homeworldVictoryReinforcement;
},
(bad: Game) => {
bad.decision = null;
},
(bad: Game) => {
delete bad.homeworldVictoryReinforcement;
bad.decision = null;
},
(bad: Game) => {
delete bad.lastBattleContext!.caladanReinforcement;
},
]) {
const bad = reload(g);
mutate(bad);
const before = structuredClone(bad);
assert.throws(() => viewGame(bad, 'a'), /Caladan|reinforcement|victory/i);
assert.throws(
() => normalizeAutomaticGame(bad),
/Caladan|reinforcement|victory/i,
);
reject(bad, 'a', action, /Caladan|reinforcement|victory/i);
assert.deepEqual(bad, before);
}
stable(g);
});
void test('a later battle at the same location owns a fresh event and cannot consume the earlier declined reinforcement', () => {
let g = victory();
const oldAction = reinforce(g);
g = applyAction(g, 'a', {
type: 'decision',
event: oldAction.event,
decline: true,
});
assert.equal(p(g, 'a').reserves, 6);
// Stage one later conserved deployment from Guild native reserves, keeping
// the completed battle history. Resolve the next battle through real actions.
positionVictoryArmy(g, 'g', 16, 1, 3);
Object.assign(g, { phase: 6, active: 'a', phaseOpening: null, ready: [] });
g = victory(g);
assert.notEqual(g.homeworldVictoryReinforcement!.event, oldAction.event);
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, false);
reject(g, 'a', oldAction, /current Caladan/);
const done = applyAction(reload(g), 'a', reinforce(g));
assert.equal(p(done, 'a').reserves, 5);
assert.equal(done.homeworldVictoryReinforcement!.stage, 'complete');
stable(done);
});
void test('a waiting Caladan victory cannot lose its real optional Shield cleanup decision', () => {
let g = caladanVictoryFixture();
const shield = holdVictoryCard(g, 'a', 'shield');
g = finishVictoryCalls(
commitVictoryPlans(prepareVictoryBattle(g), {
a: { defense: shield },
}),
);
assert.equal(g.decision?.kind, 'battleCards');
assert.equal(g.homeworldVictoryReinforcement!.stage, 'waiting');
assert.equal(g.lastBattleContext!.caladanReinforcement!.stage, 'waiting');
assert.equal(g.pendingTreacheryDiscard ?? null, null);
assert.equal(g.response, null);
assert.ok(p(g, 'a').hand.some((card) => card.id === shield));
stable(g);
const corrupt = reload(g);
corrupt.decision = null;
const before = structuredClone(corrupt);
for (const player of corrupt.players) {
assert.throws(
() => viewGame(corrupt, player.id),
/Caladan|cleanup|victory/i,
);
assert.deepEqual(corrupt, before);
}
assert.throws(
() => normalizeAutomaticGame(corrupt),
/Caladan|cleanup|victory/i,
);
assert.deepEqual(corrupt, before);
reject(
corrupt,
'a',
{
type: 'decision',
event: g.homeworldVictoryReinforcement!.event,
decline: true,
},
/Caladan|cleanup|victory/i,
);
// The original real cleanup still retires its card before exposing the offer.
const valid = applyAction(reload(g), 'a', {
type: 'decision',
discard: [shield],
});
assert.equal(valid.decision?.kind, 'caladanReinforcement');
assert.equal(valid.discard.filter((card) => card.id === shield).length, 1);
stable(valid);
});
void test('all profiles consume the real owned event and the original reinforcement precedes Face Dance', () => {
const g = victory();
const observer = viewGame(g, 'g');
assert.deepEqual(observer.caladanReinforcement!.destinations, []);
assert.deepEqual(caladanReinforcementActions(observer), []);
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(reload(g), 'a');
view.players.find((player) => player.id === 'a')!.bot = profile;
const actions = botActions(view);
assert.deepEqual(actions, caladanReinforcementActions(view));
assert.equal(actions.length, 1);
const done = applyAction(reload(g), 'a', actions[0]);
assert.equal(done.homeworldVictoryReinforcement!.stage, 'complete');
stable(done);
}
let contested = victory(caladanVictoryFixture({ tleilaxu: true }));
const offer = viewGame(contested, 'a').caladanReinforcement!;
assert.equal(offer.blocked, null);
const before = structuredClone(p(contested, 'a'));
contested = applyAction(contested, 'a', reinforce(contested));
assert.equal(p(contested, 'a').reserves, before.reserves - 1);
assert.equal(p(contested, 'a').forces['hagga_basin:12'], before.forces['hagga_basin:12'] + 1);
assert.equal(contested.homeworldVictoryReinforcement!.stage, 'complete');
if (contested.decision?.kind === 'faceDance')
contested = applyAction(contested, 't', {
type: 'decision',
reveal: false,
});
stable(contested);
});
void test('an existing signed ordering-only refusal can use the current legal Caladan destination', () => {
// Controlled historical metadata, not a restored-room or recovery proof.
let game = victory(caladanVictoryFixture({ tleilaxu: true }));
const frame = game.homeworldVictoryReinforcement!, offer = frame.offer!;
offer.blocked = 'Caladan reinforcement and Face Dancer replacement await an ordering ruling. You may leave the reserve force at home.';
offer.signature = homeworldVictoryOfferSignature(frame, offer);
assert.equal(viewGame(game, 'a').caladanReinforcement!.blocked, null);
const before = structuredClone(p(game, 'a'));
game = applyAction(game, 'a', reinforce(game));
assert.equal(p(game, 'a').reserves, before.reserves - 1);
assert.equal(p(game, 'a').forces['hagga_basin:12'], before.forces['hagga_basin:12'] + 1);
assert.equal(game.homeworldVictoryReinforcement!.stage, 'complete');
assert.equal(game.decision?.kind, 'faceDance');
game = applyAction(game, 't', { type: 'decision', reveal: false });
stable(game);
});