Files
Dune/tests/homeworld-combat.test.ts
T
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

360 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
homeworldCombatArmy,
homeworldCombatLocation,
quoteHomeworldBattles,
type HomeworldCombatContext,
} from '../game/homeworld-combat';
import {
createHomeworldCustody,
type HomeworldCustody,
} from '../game/homeworld-custody';
function fixture(advanced = true) {
const context: HomeworldCombatContext = {
advanced,
order: ['a', 'e', 'f'],
players: [
{
id: 'e',
faction: 'emperor',
reserves: 8,
eliteReserves: 2,
ally: null,
},
{ id: 'f', faction: 'fremen', reserves: 6, eliteReserves: 1, ally: null },
{
id: 'a',
faction: 'atreides',
reserves: 6,
eliteReserves: 0,
ally: null,
},
],
};
const custody: HomeworldCustody = createHomeworldCustody(context);
custody.visitors['homeworld:emperor'] = {
f: { normal: 2, elite: 2 },
a: { normal: 3, elite: 0 },
};
return { context, custody };
}
void test('Basic Emperor has one canonical native army and preserves both native and invading special counters', () => {
const { context, custody } = fixture(false);
assert.deepEqual(
homeworldCombatLocation(context, custody, 'homeworld:emperor'),
{
id: 'homeworld:emperor',
name: 'Kaitain',
native: 'e',
card: 'kaitain',
population: 8,
side: 'high',
nativeBattleStrength: 2,
forces: {
e: { normal: 6, elite: 2 },
f: { normal: 2, elite: 2 },
a: { normal: 3, elite: 0 },
},
},
);
assert.deepEqual(
homeworldCombatArmy(context, custody, 'homeworld:emperor', 'e'),
{
player: 'e',
normal: 6,
elite: 2,
native: true,
nativeBattleStrength: 2,
},
);
assert.deepEqual(
homeworldCombatArmy(context, custody, 'homeworld:emperor', 'f'),
{
player: 'f',
normal: 2,
elite: 2,
native: false,
nativeBattleStrength: 0,
},
);
assert.throws(
() => homeworldCombatLocation(context, custody, 'homeworld:emperor:salusa'),
/canonical Homeworld/,
);
});
void test('Advanced Emperor sources stay separate and each native battle bonus follows its own population measure', () => {
const { context, custody } = fixture();
assert.deepEqual(
homeworldCombatArmy(context, custody, 'homeworld:emperor', 'e'),
{
player: 'e',
normal: 6,
elite: 0,
native: true,
nativeBattleStrength: 2,
},
);
assert.deepEqual(
homeworldCombatArmy(context, custody, 'homeworld:emperor:salusa', 'e'),
{
player: 'e',
normal: 0,
elite: 2,
native: true,
nativeBattleStrength: 3,
},
);
const atBoundary = homeworldCombatLocation(
context,
custody,
'homeworld:emperor:salusa',
);
assert.equal(atBoundary.name, 'Salusa Secundus');
assert.equal(atBoundary.card, 'salusa_secundus');
assert.equal(atBoundary.population, 2);
assert.equal(atBoundary.side, 'high');
// A conserved public position after an authorized transfer elsewhere. These
// quotes do not authorize that movement or reinterpret normal men as stars.
custody.salusa = { normal: 3, elite: 1 };
const kaitain = homeworldCombatLocation(
context,
custody,
'homeworld:emperor',
);
const salusa = homeworldCombatLocation(
context,
custody,
'homeworld:emperor:salusa',
);
assert.deepEqual(
[kaitain.population, kaitain.side, kaitain.nativeBattleStrength],
[4, 'low', 3],
);
assert.deepEqual(
[salusa.population, salusa.side, salusa.nativeBattleStrength],
[1, 'low', 2],
);
assert.deepEqual(kaitain.forces.e, { normal: 3, elite: 1 });
assert.deepEqual(salusa.forces.e, { normal: 3, elite: 1 });
});
void test('three armies pair in current attacker order without a stronghold cap or native-defender preference', () => {
const { context, custody } = fixture();
assert.deepEqual(quoteHomeworldBattles(context, custody), [
{ territory: 'homeworld:emperor', attacker: 'a', defender: 'e' },
{ territory: 'homeworld:emperor', attacker: 'a', defender: 'f' },
{ territory: 'homeworld:emperor', attacker: 'e', defender: 'f' },
]);
context.order = ['f', 'e', 'a'];
assert.deepEqual(quoteHomeworldBattles(context, custody), [
{ territory: 'homeworld:emperor', attacker: 'f', defender: 'e' },
{ territory: 'homeworld:emperor', attacker: 'f', defender: 'a' },
{ territory: 'homeworld:emperor', attacker: 'e', defender: 'a' },
]);
});
void test('allied foreign armies remain separate opponents of the native and never fight one another', () => {
const { context, custody } = fixture();
context.players[1].ally = 'a';
context.players[2].ally = 'f';
assert.deepEqual(quoteHomeworldBattles(context, custody), [
{ territory: 'homeworld:emperor', attacker: 'a', defender: 'e' },
{ territory: 'homeworld:emperor', attacker: 'e', defender: 'f' },
]);
assert.deepEqual(
homeworldCombatArmy(context, custody, 'homeworld:emperor', 'f'),
{
player: 'f',
normal: 2,
elite: 2,
native: false,
nativeBattleStrength: 0,
},
);
});
void test('an empty native reserve never becomes a phantom defender and missing or zero visitors never become armies', () => {
const { context, custody } = fixture(false);
context.players[0].reserves = 0;
context.players[0].eliteReserves = 0;
assert.deepEqual(quoteHomeworldBattles(context, custody), [
{ territory: 'homeworld:emperor', attacker: 'a', defender: 'f' },
]);
assert.deepEqual(
homeworldCombatArmy(context, custody, 'homeworld:emperor', 'e'),
{
player: 'e',
normal: 0,
elite: 0,
native: true,
nativeBattleStrength: 3,
},
);
custody.visitors['homeworld:emperor'].a = { normal: 0, elite: 0 };
assert.deepEqual(quoteHomeworldBattles(context, custody), []);
assert.deepEqual(
homeworldCombatArmy(context, custody, 'homeworld:fremen', 'e'),
{
player: 'e',
normal: 0,
elite: 0,
native: false,
nativeBattleStrength: 0,
},
);
assert.throws(
() => homeworldCombatArmy(context, custody, 'homeworld:fremen', 'unseated'),
/seated Homeworld combatant/,
);
});
void test('global attacker order covers both Emperor homes without merging native reserves or changing defender-seat order', () => {
const { context, custody } = fixture();
custody.visitors['homeworld:emperor:salusa'] = { a: { normal: 1, elite: 0 } };
assert.deepEqual(quoteHomeworldBattles(context, custody), [
{ territory: 'homeworld:emperor', attacker: 'a', defender: 'e' },
{ territory: 'homeworld:emperor', attacker: 'a', defender: 'f' },
{ territory: 'homeworld:emperor:salusa', attacker: 'a', defender: 'e' },
{ territory: 'homeworld:emperor', attacker: 'e', defender: 'f' },
]);
});
void test('noncanonical aliases, inactive factions, invalid order and malformed alliances reject instead of guessing a battle context', () => {
const { context, custody } = fixture();
for (const location of [
'kaitain',
'homeworld:emperor:0',
'homeworld:emperor ',
'homeworld:Emperor',
'homeworld:choam',
'arrakeen',
'__proto__',
])
assert.throws(
() => homeworldCombatLocation(context, custody, location),
/canonical Homeworld/,
);
for (const order of [
[],
['a', 'e'],
['a', 'e', 'e'],
['a', 'e', 'unknown'],
['a', 'e', 'f', 'unknown'],
])
assert.throws(
() => quoteHomeworldBattles({ ...context, order }, custody),
/complete distinct player order/,
);
for (const ally of ['e', 'unknown', 'a']) {
const malformed = structuredClone(context);
malformed.players[0].ally = ally;
assert.throws(
() => quoteHomeworldBattles(malformed, custody),
/reciprocal alliances/,
);
}
assert.throws(
() =>
quoteHomeworldBattles(context, {
...custody,
salusa: { normal: 7, elite: 2 },
}),
/within Emperor/,
);
assert.throws(
() =>
quoteHomeworldBattles(context, {
...custody,
visitors: { 'homeworld:unseated': {} },
}),
/active Homeworld/,
);
});
void test('public-only projection ignores poisoned cards, resources, Arrakis geometry, advisors and occupation metadata', () => {
const { context, custody } = fixture();
const expected = quoteHomeworldBattles(context, custody);
const home = homeworldCombatLocation(context, custody, 'homeworld:emperor');
const army = homeworldCombatArmy(context, custody, 'homeworld:emperor', 'f');
const poison = (target: object, keys: string[]) => {
for (const key of keys)
Object.defineProperty(target, key, {
enumerable: true,
get() {
throw new Error(`Unexpected field read: ${key}`);
},
});
};
poison(context, [
'storm',
'deck',
'discard',
'spice',
'rng',
'battle',
'mobileStronghold',
'homeworldOccupation',
]);
for (const player of context.players)
poison(player, [
'hand',
'traitors',
'leaders',
'spice',
'forces',
'advisors',
'noField',
'prediction',
'faceDancers',
]);
assert.deepEqual(quoteHomeworldBattles(context, custody), expected);
assert.deepEqual(
homeworldCombatLocation(context, custody, 'homeworld:emperor'),
home,
);
assert.deepEqual(
homeworldCombatArmy(context, custody, 'homeworld:emperor', 'f'),
army,
);
});
void test('snapshots survive JSON restoration and returned pool edits never mutate input or future quotes, including special object-key seat IDs', () => {
const { context, custody } = fixture();
context.players[2].id = '__proto__';
context.order = ['__proto__', 'e', 'f'];
custody.visitors['homeworld:emperor'] = Object.fromEntries([
['f', { normal: 2, elite: 2 }],
['__proto__', { normal: 3, elite: 0 }],
]);
const before = structuredClone({ context, custody });
const home = homeworldCombatLocation(context, custody, 'homeworld:emperor');
const army = homeworldCombatArmy(
context,
custody,
'homeworld:emperor',
'__proto__',
);
assert.equal(army.normal, 3);
const expected = quoteHomeworldBattles(context, custody);
const restored = JSON.parse(
JSON.stringify({ context, custody }),
) as typeof before;
assert.deepEqual(
quoteHomeworldBattles(restored.context, restored.custody),
expected,
);
home.forces.f.normal = 19;
home.forces.e.elite = 5;
army.normal = 19;
assert.deepEqual({ context, custody }, before);
assert.equal(
homeworldCombatArmy(context, custody, 'homeworld:emperor', 'f').normal,
2,
);
assert.deepEqual(quoteHomeworldBattles(context, custody), expected);
});