Files
Dune/tests/automatic-casualties.test.ts
T
2026-09-08 07:37:52 +10:00

236 lines
7.8 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const casualtyEvents = (g: Game) =>
g.log.filter((e) => e.automatic?.name === 'Battle casualties');
function fixture(winner: FactionId = 'guild') {
const g = createGame('AUTOLOSS', newPlayer('w', 'Winner', winner));
g.players.push(newPlayer('l', 'Loser', 'emperor'));
g.status = 'playing';
g.advanced = true;
g.phase = 6;
g.turn = 2;
g.storm = 18;
g.active = 'w';
g.order = ['w', 'l'];
for (const p of g.players) {
p.forces = { 'arrakeen:10': 5 };
p.reserves = 15;
p.spice = 20;
p.hand = [];
p.traitors = [];
for (const leader of p.leaders) leader.strength = 0;
}
return g;
}
function pass(state: Game) {
let g = state;
for (let i = 0; g.response && i < 20; i++) {
const responder =
g.players.find(
(p) => p.id !== g.response!.owner && !g.response!.passed.includes(p.id),
) ?? g.players.find((p) => !g.response!.passed.includes(p.id));
assert.ok(responder);
g = applyAction(g, responder.id, { type: 'passResponse' });
}
assert.equal(g.response, null);
return g;
}
function fight(state: Game, dial = 2, support = 2) {
let g = pass(
applyAction(state, 'w', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'l',
}),
);
for (let i = 0; g.battle?.preparation && i < 10; i++)
g = pass(
applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
}),
);
assert.equal(g.battle?.preparation ?? null, null);
g = applyAction(g, 'w', {
type: 'battlePlan',
dial,
support,
leader: player(g, 'w').leaders[0].id,
defense: player(g, 'w').hand[0]?.id,
});
g = applyAction(g, 'l', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: player(g, 'l').leaders[0].id,
defense: player(g, 'l').hand[0]?.id,
});
g = applyAction(g, 'w', { type: 'traitorCall', call: false });
return applyAction(g, 'l', { type: 'traitorCall', call: false });
}
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.faction === 'ixians')
assert.equal(
p.elites!.reserves +
p.elites!.tanks +
Object.values(p.elites!.forces).reduce((a, b) => a + b, 0),
7,
);
}
}
function giveCard(g: Game, id: string, kind: Card['kind']) {
const card = baseDeck().find((c) => c.kind === kind)!;
player(g, id).hand = [card];
return card.id;
}
void test('sole ordinary advanced casualties settle before returning, conserve forces, and cannot replay after reload', () => {
const initial = fixture();
const before = structuredClone(initial);
const g = fight(initial);
assert.deepEqual(initial, before);
assert.equal(g.battle, null);
assert.notEqual(g.decision?.kind, 'battleLosses');
assert.equal(player(g, 'w').forces['arrakeen:10'], 3);
assert.equal(player(g, 'w').tanks, 2);
// Two support spice were paid, then automatic Arrakeen collection paid two.
assert.equal(player(g, 'w').spice, 20);
assert.equal(player(g, 'l').tanks, 5);
assert.equal(g.phase, 7);
assert.deepEqual(
casualtyEvents(g).map((e) => e.automatic),
[{ faction: 'guild', name: 'Battle casualties' }],
);
conserved(g);
const restored = reload(g);
const snapshot = structuredClone(restored);
assert.throws(() =>
applyAction(restored, 'w', { type: 'decision', choice: 0 }),
);
assert.deepEqual(restored, snapshot);
for (const p of restored.players) {
const view = viewGame(restored, p.id);
assert.equal(
view.log.filter((e) => e.automatic?.name === 'Battle casualties').length,
1,
);
}
assert.deepEqual(restored, snapshot);
});
void test('multiple physical casualty mixtures still require the winner to choose', () => {
const initial = fixture('fremen');
player(initial, 'w').elites = {
reserves: 2,
tanks: 0,
forces: { 'arrakeen:10': 1 },
revived: 0,
};
const g = fight(initial, 2, 0);
assert.equal(g.decision?.kind, 'battleLosses');
if (g.decision?.kind !== 'battleLosses')
throw Error('Missing casualty choice');
assert.ok(g.decision.options.length > 1);
assert.ok(g.decision.options.some((c) => c.normal === 2 && c.elite === 0));
assert.ok(g.decision.options.some((c) => c.normal === 0 && c.elite === 1));
assert.equal(player(g, 'w').tanks, 0);
assert.equal(player(g, 'w').forces['arrakeen:10'], 5);
assert.equal(casualtyEvents(g).length, 0);
conserved(g);
});
void test('sole Ixian cyborg casualty preserves optional substitution and exact typed custody', () => {
const initial = fixture('ixians');
player(initial, 'w').forces = { 'arrakeen:10': 3 };
player(initial, 'w').reserves = 17;
player(initial, 'w').elites = {
reserves: 6,
tanks: 0,
forces: { 'arrakeen:10': 1 },
revived: 0,
};
const g = fight(initial, 2, 1);
assert.equal(g.decision?.kind, 'ixSubstitution');
assert.equal(player(g, 'w').tanks, 1);
assert.equal(player(g, 'w').elites!.tanks, 1);
assert.equal(player(g, 'w').forces['arrakeen:10'], 2);
assert.equal(casualtyEvents(g).length, 1);
conserved(g);
const declined = applyAction(reload(g), 'w', {
type: 'decision',
decline: true,
});
assert.equal(player(declined, 'w').elites!.tanks, 1);
assert.equal(declined.phase, 7);
const accepted = pass(
applyAction(reload(g), 'w', {
type: 'decision',
sources: { 'arrakeen:10': 1 },
recover: { 'arrakeen:10': 1 },
}),
);
assert.equal(player(accepted, 'w').tanks, 1);
assert.equal(player(accepted, 'w').elites!.tanks, 0);
assert.equal(player(accepted, 'w').elites!.forces['arrakeen:10'], 1);
assert.equal(accepted.phase, 7);
assert.equal(casualtyEvents(accepted).length, 1);
conserved(declined);
conserved(accepted);
});
void test('automatic casualty settlement still pauses for winner played-card retention', () => {
const initial = fixture();
const shield = giveCard(initial, 'w', 'shield');
const g = fight(initial);
assert.equal(g.decision?.kind, 'battleCards');
assert.equal(player(g, 'w').tanks, 2);
assert.ok(player(g, 'w').hand.some((c) => c.id === shield));
const kept = applyAction(reload(g), 'w', { type: 'decision', discard: [] });
assert.ok(player(kept, 'w').hand.some((c) => c.id === shield));
assert.equal(kept.phase, 7);
assert.equal(casualtyEvents(kept).length, 1);
conserved(kept);
});
void test('winner cleanup after automatic casualties still continues to Moritani ally retention', () => {
const initial = fixture();
const m = newPlayer('m', 'Moritani', 'moritani');
initial.players.push(m);
initial.order.push(m.id);
m.ally = 'l';
player(initial, 'l').ally = m.id;
const shield = giveCard(initial, 'w', 'shield');
const snooper = giveCard(initial, 'l', 'snooper');
let g = fight(initial);
assert.equal(g.decision?.kind, 'battleCards');
assert.ok(g.moritaniRetention);
g = applyAction(reload(g), 'w', { type: 'decision', discard: [shield] });
assert.equal(g.decision?.kind, 'moritaniRetention');
assert.ok(player(g, 'l').hand.some((c) => c.id === snooper));
g = pass(applyAction(reload(g), 'l', { type: 'decision', keep: snooper }));
assert.equal(g.moritaniRetention, null);
assert.equal(g.phase, 7);
assert.equal(g.discard.filter((c) => c.id === shield).length, 1);
assert.ok(player(g, 'l').hand.some((c) => c.id === snooper));
assert.equal(player(g, 'w').tanks, 2);
assert.equal(casualtyEvents(g).length, 1);
conserved(g);
});