Files
Dune/tests/homeworld-battle-resolution.test.ts
Quintine 28ba1618ba Implement Homeworld mechanics through Caladan reinforcement checkpoint
Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
2026-09-10 08:12:26 +10:00

257 lines
7.4 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck, leaders } from '../game/cards';
import {
quoteBattleResolution,
type BattleResolutionInput,
type ResolutionCombatant,
} from '../game/battle-resolution-quote';
function fixture(): BattleResolutionInput {
const side = (
id: string,
faction: 'atreides' | 'guild',
): ResolutionCombatant => ({
id,
faction,
spice: 10,
hand: [],
plan: {
dial: id === 'a' ? 1 : 0,
support: 0,
leader: null,
weapon: null,
defense: null,
},
forces: { normal: 5, elite: 0, eliteStrength: 1, freeSupport: true },
});
return {
advanced: false,
turn: 2,
territory: 'homeworld:guild',
homeworld: {
native: 'd',
card: 'junction',
side: 'high',
nativeForces: { normal: 5, elite: 0 },
},
attacker: side('a', 'atreides'),
defender: side('d', 'guild'),
voters: [{ id: 'd', beneficiary: 'd', called: false, traitors: [] }],
participants: [
{ id: 'a', faction: 'atreides' },
{ id: 'd', faction: 'guild' },
],
physicalCards: baseDeck(),
pendingAuditorPresent: false,
pendingRetentionPresent: false,
};
}
function explosion(input: BattleResolutionInput) {
const cards = baseDeck();
const laser = cards.find((c) => c.kind === 'lasgun')!;
const shield = cards.find((c) => c.kind === 'shield')!;
input.attacker.hand = [...input.attacker.hand, laser];
input.attacker.plan.weapon = laser.id;
input.defender.hand = [...input.defender.hand, shield];
input.defender.plan.defense = shield.id;
}
function rejects(input: BattleResolutionInput) {
const before = structuredClone(input);
assert.throws(() => quoteBattleResolution(input));
assert.deepEqual(input, before);
}
void test('the native printed bonus changes the battle result without adding dial casualties or invading strength', () => {
const input = fixture(),
before = structuredClone(input);
const result = quoteBattleResolution(input);
assert.equal(result.winner, 'd');
assert.deepEqual(result.scores, { attacker: 1, defender: 2 });
assert.deepEqual(result.destroyedArmies, ['a']);
assert.equal(result.basicWinnerLosses, null);
assert.deepEqual(result.casualties!.options, [
{ normal: 0, elite: 0, paidNormal: 0, paidElite: 0 },
]);
assert.deepEqual(input, before);
const ordinary = structuredClone(input);
delete ordinary.homeworld;
ordinary.territory = 'arrakeen';
ordinary.voters = [
{ id: 'a', beneficiary: 'a', called: false, traitors: [] },
...ordinary.voters,
];
assert.equal(quoteBattleResolution(ordinary).winner, 'a');
});
void test('Caladan uses the verified card value and the native bonus follows either battle role', () => {
const input = fixture();
input.territory = 'homeworld:atreides';
input.homeworld = {
native: 'a',
card: 'caladan',
side: 'high',
nativeForces: { normal: 6, elite: 0 },
};
input.attacker.forces.normal = 6;
input.voters = [{ id: 'a', beneficiary: 'a', called: false, traitors: [] }];
const result = quoteBattleResolution(input);
assert.deepEqual(result.scores, { attacker: 3, defender: 0 });
assert.equal(result.winner, 'a');
assert.equal(result.casualties!.dial, 1);
});
void test('only a native combatant has a traitor vote; an invader or remote allied Harkonnen cannot call', () => {
const input = fixture();
const leader = leaders('atreides')[0];
input.attacker.leader = leader;
input.attacker.plan.leader = leader.id;
input.voters[0].called = true;
input.voters[0].traitors = [leader.id];
const result = quoteBattleResolution(input);
assert.equal(result.result, 'traitor');
assert.equal(result.winner, 'd');
assert.equal(result.homeworldExplosion, undefined);
const invader = structuredClone(input);
invader.voters = [
...invader.voters,
{ id: 'a', beneficiary: 'a', called: false, traitors: [] },
];
rejects(invader);
const ally = structuredClone(input);
ally.participants = [
...ally.participants,
{ id: 'h', faction: 'harkonnen', ally: 'd' },
];
ally.voters = [
...ally.voters,
{ id: 'h', beneficiary: 'd', called: false, traitors: [] },
];
rejects(ally);
});
void test('an explosion destroys invaders but limits typed native losses to the printed strength independently of dial', () => {
const input = fixture();
input.advanced = true;
input.territory = 'homeworld:emperor';
input.defender.faction = 'emperor';
input.participants[1].faction = 'emperor';
input.defender.forces = {
normal: 4,
elite: 1,
eliteStrength: 2,
freeSupport: false,
};
input.homeworld = {
native: 'd',
card: 'kaitain',
side: 'high',
nativeForces: { normal: 4, elite: 1 },
};
explosion(input);
const before = structuredClone(input),
result = quoteBattleResolution(input);
assert.equal(result.result, 'explosion');
assert.equal(result.winner, null);
assert.deepEqual(result.leaderDeaths, { attacker: true, defender: true });
assert.deepEqual(result.destroyedArmies, ['a']);
assert.deepEqual(result.homeworldExplosion, {
player: 'd',
amount: 2,
options: [
{ normal: 2, elite: 0 },
{ normal: 1, elite: 1 },
],
});
assert.deepEqual(input, before);
});
void test('two foreigners get no native strength or traitor votes and explosion still preserves the noncombatant native remainder', () => {
const input = fixture();
input.territory = 'homeworld:harkonnen';
input.participants = [
...input.participants,
{ id: 'native', faction: 'harkonnen' },
];
input.homeworld = {
native: 'native',
card: 'giedi_prime',
side: 'high',
nativeForces: { normal: 7, elite: 0 },
};
input.voters = [];
assert.deepEqual(quoteBattleResolution(input).scores, {
attacker: 1,
defender: 0,
});
explosion(input);
const result = quoteBattleResolution(input);
assert.deepEqual(result.destroyedArmies, ['a', 'd']);
assert.deepEqual(result.homeworldExplosion, {
player: 'native',
amount: 2,
options: [{ normal: 2, elite: 0 }],
});
});
void test('native explosion loss is capped by the actual army, including an empty native pool', () => {
for (const normal of [0, 1]) {
const input = fixture();
input.participants = [
...input.participants,
{ id: 'native', faction: 'harkonnen' },
];
input.territory = 'homeworld:harkonnen';
input.homeworld = {
native: 'native',
card: 'giedi_prime',
side: 'low',
nativeForces: { normal, elite: 0 },
};
input.voters = [];
explosion(input);
assert.deepEqual(quoteBattleResolution(input).homeworldExplosion, {
player: 'native',
amount: normal,
options: [{ normal, elite: 0 }],
});
}
});
void test('mismatched native, card, location, army and Arrakis-only effects reject without mutation', () => {
const edits: ((input: BattleResolutionInput) => void)[] = [
(i) => {
delete i.homeworld;
},
(i) => {
i.territory = 'arrakeen';
},
(i) => {
i.homeworld!.native = 'missing';
},
(i) => {
i.homeworld!.card = 'caladan';
},
(i) => {
i.homeworld!.nativeForces.normal = 4;
},
(i) => {
i.homeworld!.nativeForces.normal = -1;
},
(i) => {
i.defender.stronghold = 'arrakeen';
},
(i) => {
i.participants[0].noFieldAtTerritory = true;
},
(i) => {
i.voters = [];
},
];
for (const edit of edits) {
const input = fixture();
edit(input);
rejects(input);
}
});