// The ordered LLM-ability registry: contract + golden-prompt tests (v0.40.0, #67). // // These tests pin the refactor's two load-bearing guarantees: // // 1. GOLDEN PROMPTS — the system prompt (both tool variants) and the user // prompt the refactored engine builds are BYTE-IDENTICAL to the committed // golden fixtures in ./goldens/ (captured in P1 from the pre-refactor // engine). Any transcription drift in an ability's schemaLine / // constraint / payload is caught here. // // 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered // list of the 8 abilities; each ability's default() equals the canonical // TurnResult default for its field, is a FRESH value (never a shared // reference), its default is not "meaningful" while a populated value is, // and the registry composes (a hypothetical extra ability can be appended // and every uniform loop still runs). // // Regenerate the goldens after an INTENTIONAL prompt change with: // REGEN_GOLDENS=1 npx vitest run server/abilities/registry.test.ts // (the goldens are then rewritten from the current engine output; review the // diff and commit). In a normal run (no env) the tests assert byte-equality. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { activeWarPairs, applyIndustryEffects, atWar, buildSystemPrompt, buildUserPrompt, freshNationId, freshWarId, HYBRID_STAT_EFFECT, isHybridOpType, isIndustryType, NEW_NATION_STATS, resolveNationRef, resolveRegionKey, shiftRelationTowardHostile, syncWarRelations, validateEconomyOps, validateHybridOps, validateIdeologyOps, validateIndustryOps, validateLeaderOps, validateNuclearOps, validateTakeovers, validateWarOps, } from '../engine'; import { createPresetStore } from '../presets'; import { ABILITIES } from './index'; import type { Ability, AbilityField } from './types'; import type { Embargo, GameState, IndustryProject, Nation, NationStats, RegionResources, RelationStatus, RoundItem, TradeAgreement, } from '../../shared/types'; import type { MarkerInfo, TurnResult, WarInfo } from '../../shared/protocol'; const here = path.dirname(fileURLToPath(import.meta.url)); const goldensDir = path.join(here, 'goldens'); const REGEN = process.env.REGEN_GOLDENS === '1'; // --- The preset the system-prompt goldens were captured with. --- const root = path.resolve(here, '..', '..'); const store = createPresetStore(path.join(root, 'presets')); const preset = store.get('wwii-1939'); if (!preset) throw new Error('wwii-1939 preset not found'); // --- The small, fully-controlled deterministic state that exercises EVERY // --- optional branch of buildUserPrompt (re-embedded from the P1 // --- golden-capture so the committed goldens can be regenerated). --- function nation( id: string, name: string, stats: NationStats, territory: string[], extra: Partial = {}, ): Nation { return { id, name, type: 'major', stats, territory, relations: {}, ...extra }; } const s = (m: number, e: number, st: number, p: number): NationStats => ({ military: m, economy: e, stability: st, population: p, }); const a1 = nation('a1', 'Alpha', s(60, 55, 70, 80), ['a-r1', 'a-r2'], { nuclear: true, leader: { name: 'Alpha Leader' }, ideology: 'democracy', relations: { b2: 'ally', c3: 'rival' }, }); const b2 = nation('b2', 'Bravo', s(75, 40, 20, 30), ['b-r1', 'b-r2'], { ideology: 'fascism', relations: { a1: 'ally' }, }); const c3 = nation('c3', 'Charlie', s(30, 35, 80, 20), ['c-r1'], { relations: { a1: 'rival' }, }); const state: GameState = { id: 'golden-0000-0000-0000-0000-000000000000', presetId: 'wwii-1939', presetTitle: preset.title, playerNationId: 'a1', turn: 4, date: '1940-02-14', nations: { a1, b2, c3 }, events: [], pending: [], version: 9, unowned: ['x-unowned'], pendingNationOps: [], markers: [], regionStats: { 'a-r1': s(60, 55, 70, 80), 'a-r2': s(58, 53, 72, 78), 'b-r1': s(75, 40, 20, 30), 'b-r2': s(70, 42, 22, 28), 'c-r1': s(30, 35, 80, 20), }, wars: [], trades: [], embargoes: [], industry: [], }; // --- buildUserPrompt arguments: every optional branch exercised. --- const items: RoundItem[] = [ { kind: 'action', text: 'Rearm the northern border' }, { kind: 'diplomacy', to: 'b2', text: 'We can still talk terms, no?' }, ]; const regionReference: Record = { 'a-r1': 'Alpha North (US)', 'a-r2': 'Alpha South (US)', 'b-r1': 'Bravo Plain (RU)', 'b-r2': 'Bravo Coast (RU)', 'c-r1': 'Charlie Isle (FR)', 'x-unowned': 'X Released (UN)', }; const frontier: string[] = ['b-r1']; // owned by b2 (a non-player) → frontierMilitary const noMansLand: string[] = ['x-unowned']; const markers: MarkerInfo[] = [ { id: 'mk0001', type: 'gathering', name: 'Bravo Rally', regionKey: 'b-r1', owner: 'b2' }, { id: 'mk0002', type: 'rocket', name: 'Charlie Silo', regionKey: 'c-r1', owner: 'c3' }, { id: 'mk0003', type: 'capital', name: 'Alpha Cap', regionKey: 'a-r1', owner: 'a1' }, ]; const wars: WarInfo[] = [ { id: 'w1', sides: ['a1', 'b2'], aims: ['control the border'] }, { id: 'w2', sides: ['b2', 'c3'] }, ]; const relations: Record = { b2: 'ally', c3: 'rival' }; const trades: TradeAgreement[] = [{ a: 'a1', b: 'c3' }]; const embargoes: Embargo[] = [{ from: 'a1', target: 'b2' }]; const nationResources: Record = { a1: { oil: 0.5, iron: 0.2, food: 0.3, industry: 0.8 }, b2: { oil: 0.1, iron: 0.9, food: 0.1, industry: 0.2 }, c3: { oil: 0.2, iron: 0.3, food: 0.7, industry: 0.4 }, }; const industryProjects: IndustryProject[] = [{ regionKey: 'a-r1', type: 'industry' }]; const nuclear: Record = { a1: true, b2: false, c3: false }; /** * Assert the current prompt output is byte-identical to the committed golden — * or, under REGEN_GOLDENS=1, rewrite the golden from the current output (a * manual regeneration step; the test then passes without asserting). */ function checkGolden(name: string, current: string): void { const file = path.join(goldensDir, name); if (REGEN) { mkdirSync(goldensDir, { recursive: true }); writeFileSync(file, current, 'utf8'); return; } const expected = readFileSync(file, 'utf8'); expect(current, `golden ${name} mismatch`).toBe(expected); } /** The canonical (default / no-op) value of EVERY TurnResult field. */ function fullDefaultTurn(): TurnResult { return { date: '1939-09-01', events: [], stateChanges: [], regionChanges: [], relations: [], territoryChanges: [], unowned: [], nationOps: [], takeovers: [], wars: { declare: [], end: [] }, hybrid: { ops: [] }, economy: { trade: [], embargo: [] }, industry: { projects: [] }, nuclear: { arm: [], strikes: [] }, leaders: { changes: [] }, ideology: { changes: [] }, markers: { add: [], remove: [] }, }; } /** A "meaningful" (non-default) value for each ability field. */ function meaningfulFor(field: AbilityField): TurnResult[AbilityField] { switch (field) { case 'takeovers': return [{ nationRef: 'a1', newRegimeName: 'Alpha Regime' }]; case 'wars': return { declare: [{ sides: ['a1', 'b2'] }], end: [] }; case 'hybrid': return { ops: [{ type: 'spy', from: 'a1', target: 'b2' }] }; case 'economy': return { trade: [{ a: 'a1', b: 'c3' }], embargo: [] }; case 'industry': return { projects: [{ regionKey: 'a-r1', type: 'oil' }] }; case 'nuclear': return { arm: ['a1'], strikes: [] }; case 'leaders': return { changes: [{ target: 'a1', name: 'New Alpha Leader', cause: 'appointed' }] }; case 'ideology': return { changes: [{ target: 'a1', to: 'democracy' }] }; } } describe('ABILITIES registry (v0.40.0, #67)', () => { it('is the canonical 8-ability order (takeovers → ideology)', () => { expect(ABILITIES.map((a) => a.field)).toEqual([ 'takeovers', 'wars', 'hybrid', 'economy', 'industry', 'nuclear', 'leaders', 'ideology', ]); }); it('lists 8 distinct fields, each a key of TurnResult', () => { const fields = ABILITIES.map((a) => a.field); expect(fields).toHaveLength(8); expect(new Set(fields).size).toBe(8); const all = fullDefaultTurn(); for (const f of fields) expect(f in all).toBe(true); }); it('system prompt (no tool) is byte-identical to the committed golden', () => { checkGolden('system-no-tool.txt', buildSystemPrompt(preset, false)); }); it('system prompt (tool) is byte-identical to the committed golden', () => { checkGolden('system-tool.txt', buildSystemPrompt(preset, true)); }); it('user prompt is byte-identical to the committed golden', () => { checkGolden( 'user.txt', buildUserPrompt( state, items, regionReference, frontier, noMansLand, markers, wars, relations, trades, embargoes, nationResources, industryProjects, nuclear, ), ); }); it("each ability's default() equals the canonical TurnResult default for its field", () => { const all = fullDefaultTurn(); for (const a of ABILITIES) { expect(a.default(), `default() for ${a.field}`).toEqual(all[a.field]); } }); it('default() is a FRESH value on every call (never a shared reference)', () => { for (const a of ABILITIES) { expect(a.default(), `${a.field} default should be a fresh object`).not.toBe(a.default()); } }); it('default() is never meaningful; a populated value always is', () => { for (const a of ABILITIES) { expect(a.isMeaningful(a.default()), `${a.field} default should not be meaningful`).toBe(false); expect(a.isMeaningful(meaningfulFor(a.field)), `${a.field} populated should be meaningful`).toBe( true, ); } }); it('declares exactly the durable GameState fields the db migration must default', () => { const owned: string[] = []; for (const a of ABILITIES) if (a.stateFields) owned.push(...a.stateFields); expect([...owned].sort()).toEqual(['embargoes', 'industry', 'trades', 'wars']); }); it('composes: a hypothetical extra ability can be appended and every uniform loop still runs', () => { // The registry is a plain ordered array the engine iterates uniformly. A // structural (field-name-agnostic) loop over the abilities — schema line, // constraint bullet — must keep working when a 9th ability is appended. // This documents the extension point without widening the closed // AbilityField union. interface Loopable { field: string; schemaLine(): string; constraint(): string; } const dummy: Loopable = { field: 'custom', schemaLine: () => ' "custom": [],', constraint: () => '- A custom ability.', }; const extended: readonly (Ability | Loopable)[] = [...ABILITIES, dummy]; expect(extended.map((a) => a.field)).toEqual([ ...ABILITIES.map((a) => a.field), 'custom', ]); // every uniform loop runs over all N abilities, the appended one included: expect(extended.map((a) => a.schemaLine())).toHaveLength(9); expect(extended.map((a) => a.constraint())).toHaveLength(9); expect(extended[8].schemaLine()).toBe(' "custom": [],'); }); it('the engine still re-exports every name its callers import from it', () => { // Backward compatibility: server/index.ts and engine.test.ts import these // from the engine; the re-export block must keep them all exported (a // missing re-export would also fail typecheck — this pins it at runtime). const reexported: Record = { // shared primitives (./abilities/shared) freshNationId, freshWarId, NEW_NATION_STATS, resolveNationRef, resolveRegionKey, // ability modules (./abilities) isHybridOpType, isIndustryType, activeWarPairs, atWar, syncWarRelations, HYBRID_STAT_EFFECT, shiftRelationTowardHostile, validateEconomyOps, validateHybridOps, validateIdeologyOps, validateIndustryOps, validateLeaderOps, validateNuclearOps, validateTakeovers, validateWarOps, applyIndustryEffects, }; for (const [name, value] of Object.entries(reexported)) { expect(value, `engine should re-export ${name}`).toBeDefined(); } }); });