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

248 lines
8.8 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
type Game,
} from '../game/engine';
import { baseDeck, type Card } from '../game/cards';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture(moritani = false, twoBattles = false) {
const g = createGame('CONTEXTLIFE', newPlayer('w', 'Winner', 'guild'));
g.players.push(newPlayer('l', 'Opponent', 'emperor'));
if (moritani) g.players.push(newPlayer('m', 'Moritani', 'moritani'));
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.hand = [];
p.spice = 20;
p.traitors = [];
p.forces = p.id === 'm' ? {} : { 'arrakeen:10': 3 };
if (twoBattles && p.id !== 'm') p.forces['carthag:11'] = 3;
p.reserves = 20 - Object.values(p.forces).reduce((a, b) => a + b, 0);
for (const leader of p.leaders) leader.strength = 0;
}
if (moritani) {
g.players[1].ally = 'm';
g.players[2].ally = 'l';
}
return g;
}
function hold(g: Game, owner: string, kind: Card['kind']) {
const at = g.deck.findIndex((c) => c.kind === kind);
assert.ok(at >= 0);
const card = g.deck.splice(at, 1)[0];
g.players.find((p) => p.id === owner)!.hand.push(card);
return card.id;
}
function inventory(g: Game) {
const all = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort();
assert.equal(new Set(all).size, all.length);
return all;
}
function fight(
state: Game,
territory = 'arrakeen',
winnerDefense?: string,
loserDefense?: string,
) {
let g = applyAction(state, 'w', {
type: 'chooseBattle',
territory,
target: 'l',
});
while (g.response || g.battle?.preparation) {
if (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
else
g = applyAction(g, g.battle!.preparation!.owner, {
type: 'declineBattlePower',
});
}
const event = g.battle!.event;
for (const id of ['w', 'l']) {
const p = g.players.find((p) => p.id === id)!;
const leader = p.leaders.find(
(l) => !l.dead && (!l.usedAt || l.usedAt === territory),
)!;
assert.ok(leader);
g = applyAction(g, id, {
type: 'battlePlan',
dial: id === 'w' ? 1 : 0,
leader: leader.id,
defense: id === 'w' ? winnerDefense : loserDefense,
});
}
g = applyAction(g, 'w', { type: 'traitorCall', call: false });
g = applyAction(g, 'l', { type: 'traitorCall', call: false });
assert.equal(g.battle, null);
assert.equal(g.lastBattleContext!.event, event);
return g;
}
function resources(g: Game) {
return g.players.map((p) => ({
id: p.id,
spice: p.spice,
tanks: p.tanks,
forces: p.forces,
leaders: p.leaders,
}));
}
void test('a genuine winner-card cleanup saved without context reconstructs one legacy receipt without replaying battle effects', () => {
const initial = fixture();
const shield = hold(initial, 'w', 'shield');
const cards = inventory(initial);
let g = fight(initial, 'arrakeen', shield);
assert.equal(g.decision?.kind, 'battleCards');
const settled = resources(g);
delete g.lastBattleContext;
g = reload(g);
assert.equal(normalizeAutomaticGame(g).lastBattleContext, undefined);
g = applyAction(g, 'w', { type: 'decision', discard: [shield] });
assert.equal(g.lastBattleContext!.result, 'legacy');
assert.equal(g.lastBattleContext!.territory, 'arrakeen');
assert.deepEqual(g.lastBattleContext!.combatants, ['w', 'l']);
assert.equal(g.lastBattleContext!.winner, 'w');
assert.equal(g.lastBattleContext!.turn, 2);
assert.deepEqual(resources(g), settled);
assert.deepEqual(inventory(g), cards);
assert.equal(g.discard.filter((c) => c.id === shield).length, 1);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
assert.throws(() =>
applyAction(g, 'w', { type: 'decision', discard: [shield] }),
);
});
void test('legacy winner and Moritani cleanups share one resolved battle receipt across separate discard continuations', () => {
for (const winnerCard of [false, true]) {
const initial = fixture(true);
const shield = winnerCard ? hold(initial, 'w', 'shield') : undefined;
const snooper = hold(initial, 'l', 'snooper');
const cards = inventory(initial);
let g = fight(initial, 'arrakeen', shield, snooper);
const settled = resources(g);
delete g.lastBattleContext;
if (winnerCard) {
assert.equal(g.decision?.kind, 'battleCards');
g = applyAction(reload(g), 'w', { type: 'decision', discard: [shield!] });
assert.equal(g.lastBattleContext!.result, 'legacy');
}
assert.equal(g.decision?.kind, 'moritaniRetention');
const event = g.lastBattleContext?.event;
g = applyAction(reload(g), 'l', { type: 'decision', keep: null });
assert.equal(g.moritaniRetention, null);
assert.equal(g.lastBattleContext!.result, 'legacy');
if (event) assert.equal(g.lastBattleContext!.event, event);
assert.deepEqual(g.lastBattleContext!.combatants, ['w', 'l']);
assert.equal(g.lastBattleContext!.winner, 'w');
assert.deepEqual(resources(g), settled);
assert.deepEqual(inventory(g), cards);
assert.equal(g.discard.filter((c) => c.id === snooper).length, 1);
assert.equal(g.resolvedTreacheryDiscardSequence, winnerCard ? 2 : 1);
}
});
void test('a second genuine battle in the same phase replaces the first context before its own card cleanup', () => {
const initial = fixture(false, true);
const shield = hold(initial, 'w', 'shield');
const firstDefense = hold(initial, 'l', 'snooper');
const secondDefense = hold(initial, 'l', 'snooper');
const cards = inventory(initial);
let g = fight(initial, 'arrakeen', shield, firstDefense);
const first = structuredClone(g.lastBattleContext!);
g = applyAction(g, 'w', { type: 'decision', discard: [] });
assert.equal(g.phase, 6);
g = fight(reload(g), 'carthag', shield, secondDefense);
assert.notEqual(g.lastBattleContext!.event, first.event);
assert.equal(g.lastBattleContext!.territory, 'carthag');
assert.equal(g.lastBattleContext!.turn, first.turn);
assert.equal(g.lastBattleContext!.result, 'normal');
const second = structuredClone(g.lastBattleContext!);
g = applyAction(g, 'w', { type: 'decision', discard: [shield] });
assert.deepEqual(g.lastBattleContext, second);
assert.deepEqual(inventory(g), cards);
assert.equal(g.resolvedTreacheryDiscardSequence, 3);
assert.equal(
g.discard.filter((c) =>
[shield, firstDefense, secondDefense].includes(c.id),
).length,
3,
);
});
void test('a later-turn genuine battle replaces a retained previous-turn context even in the same territory', () => {
const initial = fixture();
const shield = hold(initial, 'w', 'shield');
let g = fight(initial, 'arrakeen', shield);
g = applyAction(g, 'w', { type: 'decision', discard: [] });
const first = structuredClone(g.lastBattleContext!);
assert.equal(g.phase, 7);
// Advance the actual Collection and Mentat boundaries into the next turn.
for (let phase = 0; phase < 2; phase++)
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.turn, 3);
assert.deepEqual(g.lastBattleContext, first);
// Stage the later battle's forces after intervening phases; both battles and
// their cleanup execute real public actions. No prior context is normalized.
g.phase = 6;
g.active = 'w';
g.response = null;
g.decision = null;
g.phaseOpening = null;
g.players[1].forces = { 'arrakeen:10': 1 };
g.players[1].reserves--;
g = fight(reload(g), 'arrakeen', shield);
assert.equal(g.lastBattleContext!.turn, 3);
assert.equal(g.lastBattleContext!.territory, 'arrakeen');
assert.notEqual(g.lastBattleContext!.event, first.event);
assert.equal(g.lastBattleContext!.result, 'normal');
g = applyAction(g, 'w', { type: 'decision', discard: [shield] });
assert.equal(g.discard.filter((c) => c.id === shield).length, 1);
assert.equal(g.lastBattleContext!.turn, 3);
});
void test('existing mismatched context is rejected rather than being silently replaced with a legacy receipt', () => {
const initial = fixture();
const shield = hold(initial, 'w', 'shield');
const resolved = fight(initial, 'arrakeen', shield);
for (const corrupt of [
(g: Game) => {
g.lastBattleContext!.turn--;
},
(g: Game) => {
g.lastBattleContext!.territory = 'carthag';
},
(g: Game) => {
g.lastBattleContext!.winner = 'l';
},
(g: Game) => {
g.lastBattleContext!.combatants.reverse();
},
]) {
const g = reload(resolved);
corrupt(g);
const before = reload(g);
assert.throws(() =>
applyAction(g, 'w', { type: 'decision', discard: [shield] }),
);
assert.deepEqual(g, before);
}
});