Files
Dune/tests/homeworld-setup-engine.test.ts

473 lines
16 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeBaseGameForAudit,
initializeHomeworldGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
import { FACTIONS, type FactionId } from '../game/catalog';
import { location, territory, MOBILE_STRONGHOLD } from '../game/board';
const BASE: FactionId[] = [
'atreides',
'harkonnen',
'emperor',
'fremen',
'guild',
'beneGesserit',
];
const EXPANSION: FactionId[] = [
'ixians',
'tleilaxu',
'emperor',
'fremen',
'guild',
'beneGesserit',
];
const sum = (counts: Record<string, number>) =>
Object.values(counts).reduce((a, b) => a + b, 0);
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
function lobby(roster: FactionId[] = BASE, advanced = true, enabled = true) {
const expansions = [
...new Set(
roster.map((id) => FACTIONS.find((f) => f.id === id)!.expansion),
),
].filter((id) => id !== 'base');
let g = createGame(
'HOMEWORLDSETUP',
newPlayer(roster[0], roster[0], roster[0]),
advanced,
expansions,
);
for (const id of roster.slice(1)) joinGame(g, newPlayer(id, id, id));
if (enabled)
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
for (const id of roster) g = applyAction(g, id, { type: 'ready' });
return g;
}
function rejectUnchanged(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function assertPrivateViews(g: Game) {
const first = viewGame(g, g.players[0].id).homeworlds;
for (const p of g.players) {
const view = viewGame(g, p.id);
assert.deepEqual(
view.homeworlds,
first,
'Physical Homeworld counts are public for every seat.',
);
assert.equal('deck' in view, false);
assert.equal('traitorReserve' in view, false);
for (const other of view.players.filter((other) => other.id !== p.id)) {
assert.equal(other.hand, undefined);
assert.equal(other.traitors, undefined);
assert.equal(other.traitorChoices, undefined);
assert.equal(other.prediction, undefined);
}
}
}
function assertBeforeForces(g: Game) {
assert.ok(g.setupStage === 'prediction' || g.setupStage === 'traitors');
assert.equal(g.homeworlds!.custody, null);
assert.deepEqual(viewGame(g, g.host).homeworlds, { worlds: null });
for (const p of g.players) {
assert.equal(p.reserves, 20);
assert.equal(p.spice, 0);
assert.deepEqual(p.forces, {});
assert.deepEqual(p.hand, []);
}
assertPrivateViews(g);
}
function toForces(state: Game) {
let g = state;
if (g.setupStage === 'prediction') {
assertBeforeForces(g);
g = applyAction(g, 'beneGesserit', {
type: 'predict',
faction: g.players.find((p) => p.faction !== 'beneGesserit')!.faction,
turn: 4,
});
}
for (let i = 0; g.setupStage === 'traitors' && i < 6; i++) {
assertBeforeForces(g);
const p = g.players.find((p) => p.traitorChoices.length)!;
assert.ok(p);
g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
}
assert.notEqual(g.setupStage, 'traitors');
assert.ok(g.homeworlds!.custody);
return g;
}
function fremenPlacement(elite: number): Action {
const key = location('sietch_tabr', territory('sietch_tabr').sectors[0]);
return {
type: 'fremenSetup',
placements: { [key]: 10 },
elitePlacements: { [key]: elite },
};
}
function finishSetup(state: Game, difficulty: Difficulty = 'Easy') {
let g = state;
const actions: string[] = [];
for (let step = 0; g.status === 'setup' && step < 40; step++) {
assertPrivateViews(g);
let progressed = false;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((owner) => owner.id === p.id)!.bot = difficulty;
const before = structuredClone(view);
const candidates = botActions(view);
assert.deepEqual(view, before);
if (!candidates.length) continue;
for (const candidate of candidates)
assert.doesNotThrow(
() => applyAction(g, p.id, candidate),
`${difficulty}/${p.id}/${JSON.stringify(candidate)}`,
);
actions.push(`${g.decision?.kind ?? g.setupStage}:${candidates[0].type}`);
g = applyAction(g, p.id, candidates[0]);
g = JSON.parse(JSON.stringify(g)) as Game;
progressed = true;
break;
}
assert.ok(
progressed,
`Setup stalled at ${g.setupStage}/${g.decision?.kind}`,
);
}
assert.equal(g.status, 'playing');
assert.equal(g.phase, 0);
assert.equal(g.turn, 1);
assert.equal(g.setupStage, undefined);
return { game: g, actions };
}
function assertInventory(g: Game) {
const worlds = viewGame(g, g.host).homeworlds!.worlds!;
assert.equal(
worlds.length,
g.players.length +
Number(g.advanced && g.players.some((p) => p.faction === 'emperor')),
);
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + sum(p.forces),
20,
`${p.faction} physical counters`,
);
const expected =
p.faction === 'ixians'
? 7
: p.faction === 'emperor'
? 5
: p.faction === 'fremen'
? 3
: 0;
if (expected) {
assert.ok(p.elites);
assert.equal(
p.elites.reserves + p.elites.tanks + sum(p.elites.forces),
expected,
`${p.faction} typed counters`,
);
} else assert.equal(p.elites, undefined);
const own = worlds.filter((w) => w.native === p.id);
assert.equal(
own.reduce((n, w) => n + w.forces[p.id].normal + w.forces[p.id].elite, 0),
p.reserves,
);
assert.equal(
own.reduce((n, w) => n + w.forces[p.id].elite, 0),
p.elites?.reserves ?? 0,
);
assert.ok(own.every((w) => Object.keys(w.forces).length === 1));
}
assertPrivateViews(g);
}
function assertCardCustody(g: Game) {
const physical = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...(g.richeseCache ?? []),
];
assert.equal(new Set(physical.map((card) => card.id)).size, physical.length);
assert.equal(g.ixSetupCards ?? null, null);
}
void test('Homeworld lobby choice belongs to the host, resets readiness, and opens the genuine Homeworld start', () => {
let g = lobby(['atreides', 'emperor'], false, false);
rejectUnchanged(g, 'emperor', { type: 'homeworlds', enabled: true });
rejectUnchanged(g, g.host, { type: 'homeworlds', enabled: 1 });
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
assert.deepEqual(g.homeworlds, { custody: null });
assert.ok(g.players.every((p) => !p.ready));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.ok(applyAction(g, g.host, { type: 'start' }).homeworlds, 'A normal start keeps the selected Homeworld module');
const before = structuredClone(g);
assert.throws(() => initializeBaseGameForAudit(g));
assert.deepEqual(g, before);
g = applyAction(g, g.host, { type: 'homeworlds', enabled: false });
assert.equal(g.homeworlds, null);
assert.ok(g.players.every((p) => !p.ready));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(initializeBaseGameForAudit(g).status, 'setup');
});
void test('Homeworld setup waits for private prediction and every traitor before creating native custody', () => {
const ready = lobby();
const before = structuredClone(ready);
let g = initializeHomeworldGameForAudit(ready);
assert.deepEqual(ready, before);
assert.equal(g.setupStage, 'prediction');
assertBeforeForces(g);
rejectUnchanged(g, 'fremen', fremenPlacement(3));
g = toForces(g);
assert.equal(g.setupStage, 'forces');
assert.ok(g.players.every((p) => p.hand.length === 0));
assert.deepEqual(player(g, 'beneGesserit').prediction, {
faction: 'atreides',
turn: 4,
});
assertInventory(g);
rejectUnchanged(g, 'beneGesserit', {
type: 'predict',
faction: 'emperor',
turn: 1,
});
});
void test('the eight supported base and Ix faction setup paths conserve native and special counters in both modes at all four bot levels', () => {
for (const advanced of [false, true])
for (const roster of [BASE, EXPANSION])
for (const difficulty of DIFFICULTIES) {
const { game: g, actions } = finishSetup(
initializeHomeworldGameForAudit(lobby(roster, advanced)),
difficulty,
);
assertInventory(g);
assertCardCustody(g);
for (const p of g.players) {
assert.equal(
p.spice,
FACTIONS.find((f) => f.id === p.faction)!.spice,
);
assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
}
if (roster === EXPANSION) {
assert.ok(actions.some((a) => a === 'ixSetup:decision'));
assert.equal(player(g, 'ixians').elites!.reserves, 4);
assert.equal(
player(g, 'ixians').elites!.forces[location(MOBILE_STRONGHOLD, 0)],
3,
);
}
}
});
void test('unprototyped CHOAM/Richese and Ecaz/Moritani combinations reject genuine Homeworld setup without replacing decks or lobby metadata', () => {
for (const roster of [
['choam', 'richese'],
['ecaz', 'moritani'],
] as FactionId[][]) {
for (const advanced of [false, true]) {
const g = lobby(roster, advanced);
const before = structuredClone(g);
assert.throws(() => initializeHomeworldGameForAudit(g));
assert.deepEqual(g, before);
assert.deepEqual(g.deck, []);
assert.deepEqual(g.homeworlds, { custody: null });
assert.ok(g.players.every((p) => p.ready && p.reserves === 20));
}
}
});
void test('Emperor starts with a single Basic Kaitain inventory or five Sardaukar on Advanced Salusa', () => {
for (const advanced of [false, true]) {
const g = finishSetup(
initializeHomeworldGameForAudit(lobby(['emperor', 'atreides'], advanced)),
).game;
const worlds = viewGame(g, 'emperor').homeworlds!.worlds!;
const kaitain = worlds.find((w) => w.card === 'kaitain')!;
const salusa = worlds.find((w) => w.card === 'salusa_secundus');
assert.deepEqual(kaitain.forces.emperor, {
normal: 15,
elite: advanced ? 0 : 5,
});
assert.equal(kaitain.population, advanced ? 15 : 20);
if (advanced) {
assert.ok(salusa);
assert.deepEqual(salusa.forces.emperor, { normal: 0, elite: 5 });
assert.equal(salusa.population, 5);
assert.equal(salusa.side, 'high');
} else assert.equal(salusa, undefined);
assertInventory(g);
}
});
void test('real mixed Ix setup supports every smaller table size and all three physical special-counter identities together', () => {
const roster: FactionId[] = [
'ixians',
'emperor',
'fremen',
'atreides',
'beneGesserit',
];
for (const advanced of [false, true])
for (const count of [2, 3, 4, 5]) {
const { game: g, actions } = finishSetup(
initializeHomeworldGameForAudit(
lobby(roster.slice(0, count), advanced),
),
);
assertInventory(g);
assertCardCustody(g);
assert.ok(actions.includes('ixSetup:decision'));
assert.equal(player(g, 'ixians').elites!.reserves, 4);
assert.equal(sum(player(g, 'ixians').elites!.forces), 3);
}
});
void test('native public projection survives JSON restart and is independent of hidden prediction and spice balances', () => {
const g = finishSetup(initializeHomeworldGameForAudit(lobby())).game;
const changed = JSON.parse(JSON.stringify(g)) as Game;
player(changed, 'beneGesserit').prediction = { faction: 'fremen', turn: 9 };
player(changed, 'emperor').spice += 17;
const expected = viewGame(g, 'atreides').homeworlds;
for (const p of changed.players)
assert.deepEqual(viewGame(changed, p.id).homeworlds, expected);
assertPrivateViews(changed);
assert.deepEqual(normalizeAutomaticGame(changed), changed);
});
void test('actual Fremen star placement changes the native typed projection without changing its ten remaining physical reserves', () => {
for (const advanced of [false, true])
for (const elite of [0, 1, 3]) {
let g = toForces(
initializeHomeworldGameForAudit(lobby(['fremen', 'emperor'], advanced)),
);
assert.equal(
viewGame(g, 'fremen').homeworlds!.worlds!.find(
(w) => w.native === 'fremen',
)!.population,
20,
);
g = applyAction(g, 'fremen', fremenPlacement(elite));
const home = viewGame(g, 'fremen').homeworlds!.worlds!.find(
(w) => w.native === 'fremen',
)!;
assert.deepEqual(home.forces.fremen, {
normal: 7 + elite,
elite: 3 - elite,
});
assert.equal(home.population, 10);
assert.equal(sum(player(g, 'fremen').elites!.forces), elite);
assertInventory(g);
}
});
void test('setup rejects impossible star placement atomically and preserves the real pending choice for a legal replacement', () => {
const g = toForces(
initializeHomeworldGameForAudit(lobby(['fremen', 'emperor'], false)),
);
rejectUnchanged(g, 'fremen', fremenPlacement(4));
assertInventory(applyAction(g, 'fremen', fremenPlacement(3)));
});
function corruptRejected(g: Game) {
const before = structuredClone(g);
assert.throws(() => viewGame(g, g.host));
assert.deepEqual(g, before);
assert.throws(() => normalizeAutomaticGame(g));
assert.deepEqual(g, before);
rejectUnchanged(g, g.host, { type: 'setAutopilot', difficulty: 'Easy' });
}
void test('saved native custody cannot be missing, orphaned, duplicated, or reconstructed during view, action, or normalization', () => {
const original = finishSetup(
initializeHomeworldGameForAudit(lobby(['emperor', 'fremen'], true)),
).game;
const mutations: ((g: Game) => void)[] = [
(g) => {
g.homeworlds!.custody = null;
},
(g) => {
g.homeworlds!.custody!.salusa = null;
},
(g) => {
g.homeworlds!.custody!.salusa!.elite = 6;
},
(g) => {
g.homeworlds!.custody!.salusa!.normal = -1;
},
(g) => {
g.homeworlds!.custody!.visitors['homeworld:absent'] = {
emperor: { normal: 1, elite: 0 },
};
},
(g) => {
g.homeworlds!.custody!.visitors['homeworld:emperor'] = {
emperor: { normal: 1, elite: 0 },
};
},
(g) => {
player(g, 'emperor').reserves--;
},
(g) => {
player(g, 'fremen').elites!.reserves--;
},
(g) => {
(g.homeworlds as unknown as Record<string, unknown>).orphan = true;
},
];
for (const mutate of mutations) {
const g = JSON.parse(JSON.stringify(original)) as Game;
mutate(g);
corruptRejected(g);
}
assert.deepEqual(
normalizeAutomaticGame(JSON.parse(JSON.stringify(original))),
original,
);
const early = initializeHomeworldGameForAudit(lobby());
early.homeworlds!.custody = structuredClone(original.homeworlds!.custody);
corruptRejected(early);
});
void test('the Homeworld audit seam refuses a missing module, unready lobby, dirty counters, or an already initialized game without mutating it', () => {
const candidates = [
lobby(BASE, false, false),
lobby(),
lobby(),
initializeHomeworldGameForAudit(lobby()),
];
candidates[1].players[0].ready = false;
candidates[2].players[0].reserves--;
for (const g of candidates) {
const before = structuredClone(g);
assert.throws(() => initializeHomeworldGameForAudit(g));
assert.deepEqual(g, before);
}
});
void test('legacy Basic setup without the module preserves untyped base reserves and has no Homeworld view', () => {
const g = finishSetup(
initializeBaseGameForAudit(lobby(BASE, false, false)),
).game;
assert.equal(g.homeworlds, undefined);
assert.equal(viewGame(g, g.host).homeworlds, null);
assert.ok(g.players.every((p) => p.elites === undefined));
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(g))), g);
});