CI / build + unit + e2e (pull_request) Successful in 2m10s
A third region state (contested frontline): a region in no nation's territory and not in unowned, opened/resolved by the LLM, tied to an active war. Sole-ownership invariant preserved. Durable GameState.contested; contested-inert stub; map legend stays at 7.
471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
// 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 engine builds are BYTE-IDENTICAL to the committed golden
|
|
// fixtures in ./goldens/ (captured in P1 from the pre-refactor engine, and
|
|
// re-baselined when a feature INTENTIONALLY changes the prompt — e.g. the
|
|
// system goldens were regenerated in v0.41.0 when the disasters ability
|
|
// was added). Any transcription drift in an ability's schemaLine /
|
|
// constraint / payload is caught here. The USER prompt golden is unchanged
|
|
// by the disasters feature (its payload is always empty).
|
|
//
|
|
// 2. REGISTRY CONTRACT — the ABILITIES registry is the canonical ordered
|
|
// list of the 12 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,
|
|
applyDisasterEvent,
|
|
applyIndustryEffects,
|
|
applyMigrationOp,
|
|
atWar,
|
|
buildSystemPrompt,
|
|
buildUserPrompt,
|
|
DISASTER_EFFECTS,
|
|
DISASTER_SEVERITIES,
|
|
DISASTER_TYPES,
|
|
freshNationId,
|
|
freshWarId,
|
|
HYBRID_STAT_EFFECT,
|
|
isDisasterSeverity,
|
|
isDisasterType,
|
|
isHybridOpType,
|
|
isIndustryType,
|
|
isMigrationKind,
|
|
isMigrationScale,
|
|
LARGE_INFLUX_STABILITY_DIP,
|
|
MIGRATION_DESTINATION_EFFECTS,
|
|
MIGRATION_KINDS,
|
|
MIGRATION_SCALES,
|
|
MIGRATION_SOURCE_EFFECTS,
|
|
NEW_NATION_STATS,
|
|
resolveNationRef,
|
|
resolveRegionKey,
|
|
shiftRelationTowardHostile,
|
|
shiftRelationTowardPositive,
|
|
syncWarRelations,
|
|
validateDisasterEvents,
|
|
validateEconomyOps,
|
|
validateHybridOps,
|
|
validateIdeologyOps,
|
|
validateIndustryOps,
|
|
validateLeaderOps,
|
|
validateMigrationOps,
|
|
validateNuclearOps,
|
|
validateTakeovers,
|
|
validateWarOps,
|
|
validateSeaOps,
|
|
applySeaControl,
|
|
applySeaZoneBonuses,
|
|
SEA_ZONE_BONUS,
|
|
SEA_ZONES,
|
|
SEA_ZONE_IDS,
|
|
isSeaZoneId,
|
|
seaZoneName,
|
|
// contested (v0.44.0, #66)
|
|
validateContestedOps,
|
|
applyContested,
|
|
CONTESTED_FILL,
|
|
CONTESTED_STROKE,
|
|
contestedTooltipLabel,
|
|
} 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,
|
|
SeaZoneClaim,
|
|
TradeAgreement,
|
|
} from '../../shared/types';
|
|
import type { ContestedRegion, 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> = {},
|
|
): 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: [],
|
|
// (v0.43.0, #65) no sea-zone control on a fresh state.
|
|
seaZones: [],
|
|
// (v0.44.0, #66) no contested regions on a fresh state.
|
|
contested: [],
|
|
};
|
|
|
|
// --- 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<string, string> = {
|
|
'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<string, RelationStatus> = { b2: 'ally', c3: 'rival' };
|
|
const trades: TradeAgreement[] = [{ a: 'a1', b: 'c3' }];
|
|
const embargoes: Embargo[] = [{ from: 'a1', target: 'b2' }];
|
|
const nationResources: Record<string, RegionResources> = {
|
|
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<string, boolean> = { a1: true, b2: false, c3: false };
|
|
const seaZones: SeaZoneClaim[] = [];
|
|
// (v0.44.0, #66) a non-empty contested sample so the golden's user payload
|
|
// includes the `contested` key (region key + party NAMES, resolved from the
|
|
// state's nations). `b-r1` is owned by b2; the parties are a1 + b2.
|
|
const contested: ContestedRegion[] = [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }];
|
|
|
|
/**
|
|
* 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: [] },
|
|
disasters: { events: [] },
|
|
migration: { ops: [] },
|
|
sea: { control: [] },
|
|
contested: { open: [], resolve: [] },
|
|
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' }] };
|
|
case 'disasters':
|
|
return { events: [{ type: 'earthquake', target: 'a1', severity: 'minor' }] };
|
|
case 'migration':
|
|
return { ops: [{ kind: 'migration', from: 'a1', to: 'b2', scale: 'small' }] };
|
|
case 'sea':
|
|
return { control: [{ zone: 'pacific', by: 'a1' }] };
|
|
case 'contested':
|
|
return { open: [{ regionKey: 'b-r1', parties: ['a1', 'b2'] }], resolve: [] };
|
|
}
|
|
}
|
|
|
|
describe('ABILITIES registry (v0.40.0, #67)', () => {
|
|
it('is the canonical 12-ability order (takeovers → contested)', () => {
|
|
expect(ABILITIES.map((a) => a.field)).toEqual([
|
|
'takeovers',
|
|
'wars',
|
|
'hybrid',
|
|
'economy',
|
|
'industry',
|
|
'nuclear',
|
|
'leaders',
|
|
'ideology',
|
|
'disasters',
|
|
'migration',
|
|
'sea',
|
|
'contested',
|
|
]);
|
|
});
|
|
|
|
it('lists 12 distinct fields, each a key of TurnResult', () => {
|
|
const fields = ABILITIES.map((a) => a.field);
|
|
expect(fields).toHaveLength(12);
|
|
expect(new Set(fields).size).toBe(12);
|
|
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,
|
|
seaZones,
|
|
contested,
|
|
),
|
|
);
|
|
});
|
|
|
|
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(['contested', 'embargoes', 'industry', 'seaZones', '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 12th 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<AbilityField> | 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(13);
|
|
expect(extended.map((a) => a.constraint())).toHaveLength(13);
|
|
expect(extended[12].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<string, unknown> = {
|
|
// 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,
|
|
// disasters (v0.41.0, #63)
|
|
validateDisasterEvents,
|
|
applyDisasterEvent,
|
|
isDisasterType,
|
|
isDisasterSeverity,
|
|
DISASTER_TYPES,
|
|
DISASTER_SEVERITIES,
|
|
DISASTER_EFFECTS,
|
|
// migration (v0.42.0, #64)
|
|
validateMigrationOps,
|
|
applyMigrationOp,
|
|
shiftRelationTowardPositive,
|
|
isMigrationKind,
|
|
isMigrationScale,
|
|
MIGRATION_KINDS,
|
|
MIGRATION_SCALES,
|
|
MIGRATION_SOURCE_EFFECTS,
|
|
MIGRATION_DESTINATION_EFFECTS,
|
|
LARGE_INFLUX_STABILITY_DIP,
|
|
// sea (v0.43.0, #65)
|
|
validateSeaOps,
|
|
applySeaControl,
|
|
applySeaZoneBonuses,
|
|
SEA_ZONE_BONUS,
|
|
SEA_ZONES,
|
|
SEA_ZONE_IDS,
|
|
isSeaZoneId,
|
|
seaZoneName,
|
|
// contested (v0.44.0, #66)
|
|
validateContestedOps,
|
|
applyContested,
|
|
CONTESTED_FILL,
|
|
CONTESTED_STROKE,
|
|
contestedTooltipLabel,
|
|
};
|
|
for (const [name, value] of Object.entries(reexported)) {
|
|
expect(value, `engine should re-export ${name}`).toBeDefined();
|
|
}
|
|
});
|
|
});
|