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) => 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).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); });