refactor(#74) P4: wire engine/stub/db through the ABILITIES registry

Behavior-preserving rewire: every per-ability code path now iterates the
ordered ABILITIES registry instead of hand-enumerating the eight fields.

- engine.ts: buildSystemPrompt (schema lines + constraint bullets),
  buildUserPrompt (payload keys at their historical positions),
  validateTurnResult (per-field validate), applyTurnResult (ordered apply
  block), isMeaningful, and the runTurn "unclear" fallback all drive off
  ABILITIES. The eight moved ability bodies (~1,249 lines) are deleted;
  the names the engine and its tests import are re-exported from
  ./abilities.
- abilities/types.ts: Ability.stub is a zero-arg method (the stub has no
  GameState/AbilityCtx to pass; it needs none) — all eight implementations
  were already arg-less.
- stub.ts: deterministicTurn fills the eight ability fields from the
  registry (each contributes its inert default), between the core fields
  and markers — JSON output stays byte-identical (verified against
  origin/master).
- db.ts: the legacy-row migration for ability-owned GameState fields
  (wars/trades/embargoes/industry) iterates the registry's stateFields,
  only-if-undefined, instead of a hardcoded field list.

No client changes, no new deps, no shared/ runtime deps. typecheck + lint
+ 511 unit tests green; stub JSON byte-identical to origin/master.

Fixes #74
This commit is contained in:
2026-10-16 07:23:11 +00:00
parent 2d17c743f6
commit 15a1642eec
4 changed files with 129 additions and 1612 deletions
+3 -2
View File
@@ -158,9 +158,10 @@ export interface Ability<K extends AbilityField = AbilityField> {
/** /**
* The deterministic stub's value for this field. Every ability returns * The deterministic stub's value for this field. Every ability returns
* `default()` (the stub never exercises an ability, so e2e is unaffected) — * `default()` (the stub never exercises an ability, so e2e is unaffected) —
* kept as a method so the stub stays a uniform registry loop. * kept as a zero-arg method so the stub stays a uniform registry loop (the
* stub has no real `GameState` / `AbilityCtx` to hand one; it needs none).
*/ */
stub(state: GameState, ctx: AbilityCtx): TurnResult[K]; stub(): TurnResult[K];
/** /**
* The keys this ability contributes to the user prompt payload, each included * The keys this ability contributes to the user prompt payload, each included
+15 -11
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { migrateRegionStats } from '../shared/protocol'; import { migrateRegionStats } from '../shared/protocol';
import type { GameState } from '../shared/types'; import type { GameState } from '../shared/types';
import { ABILITIES } from './abilities';
export interface Db { export interface Db {
save(state: GameState): void; save(state: GameState): void;
@@ -49,17 +50,20 @@ export function openDb(filePath: string): Db {
if (state.pendingNationOps === undefined) state.pendingNationOps = []; if (state.pendingNationOps === undefined) state.pendingNationOps = [];
// (v0.27, #44) Same for map markers: a legacy game has none placed. // (v0.27, #44) Same for map markers: a legacy game has none placed.
if (state.markers === undefined) state.markers = []; if (state.markers === undefined) state.markers = [];
// (v0.33.0, #56) Same for named wars: a legacy game has none declared // (v0.33.0 #56, v0.35.0 #58, v0.36 #59, v0.40.0 #67) Same for every
// (any pre-v0.33 `war` relation stays a bare/implicit relation — see // ability-owned GameState field (wars / trades / embargoes / industry):
// syncWarRelations, which never force-creates a War entity). // a legacy game has none of them. The ABILITIES registry is the single
if (state.wars === undefined) state.wars = []; // source of truth for which fields the LLM abilities own — each is a
// (v0.35.0, #58) Same for the trade & embargo economy layer: a // list, so the default is `[]`, applied only-if-undefined so a saved
// legacy game has no active trades or embargoes. // value is never wiped. (Any pre-v0.33 `war` relation stays a
if (state.trades === undefined) state.trades = []; // bare/implicit relation — see syncWarRelations, which never
if (state.embargoes === undefined) state.embargoes = []; // force-creates a War entity.)
// (v0.36, #59) Same for the industry layer: a legacy game has no for (const a of ABILITIES) {
// active industry projects. const fields = a.stateFields;
if (state.industry === undefined) state.industry = []; if (!fields) continue;
const row = state as unknown as Record<string, unknown>;
for (const f of fields) if (row[f] === undefined) row[f] = [];
}
// (v0.28, #46) Rows saved before per-region stats existed are migrated // (v0.28, #46) Rows saved before per-region stats existed are migrated
// ONCE at load time (and never re-migrated afterwards — the field is // ONCE at load time (and never re-migrated afterwards — the field is
// then present): each owned region is seeded with a copy of its owner's // then present): each owned region is seeded with a copy of its owner's
+82 -1590
View File
File diff suppressed because it is too large Load Diff
+29 -9
View File
@@ -4,6 +4,7 @@
import { createLlmClient, type ChatMessage, type Transport } from './llm'; import { createLlmClient, type ChatMessage, type Transport } from './llm';
import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol'; import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
import type { RelationStatus, RoundItem } from '../shared/types'; import type { RelationStatus, RoundItem } from '../shared/types';
import { ABILITIES, type AbilityField } from './abilities';
export const ADVISOR_MARKER = '<<ADVISOR>>'; export const ADVISOR_MARKER = '<<ADVISOR>>';
@@ -229,7 +230,18 @@ export function deterministicTurn(p: StubPayload): TurnResult {
}, },
); );
return { // Core (non-ability) turn fields, in their historical position order.
const pre: Pick<
TurnResult,
| 'date'
| 'events'
| 'stateChanges'
| 'regionChanges'
| 'relations'
| 'territoryChanges'
| 'unowned'
| 'nationOps'
> = {
date: addDays(state?.date ?? '1939-09-01', 2), date: addDays(state?.date ?? '1939-09-01', 2),
events, events,
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }], stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
@@ -238,16 +250,24 @@ export function deterministicTurn(p: StubPayload): TurnResult {
territoryChanges, territoryChanges,
unowned: [], // the stub never creates no man's land (see doc comment) unowned: [], // the stub never creates no man's land (see doc comment)
nationOps: [], // the stub never proposes nation ops (see doc comment) nationOps: [], // the stub never proposes nation ops (see doc comment)
takeovers: [], // the stub never performs a takeover (see doc comment) };
wars: { declare: [], end: [] }, // the stub never declares/ends wars (see doc comment)
hybrid: { ops: [] }, // the stub never runs hybrid/covert ops (see doc comment) // The 8 ability fields, filled by the registry in its canonical order. The
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment) // stub is ability-inert: every ability contributes its own no-op default
industry: { projects: [] }, // the stub never declares industry projects (see doc comment) // (each ability's `stub()` — see the per-ability doc comments above), so the
nuclear: { arm: [], strikes: [] }, // the stub never arms or strikes (see doc comment) // e2e assertions are unaffected. The insertion order here (ABILITIES) is the
leaders: { changes: [] }, // the stub never changes leaders (see doc comment) // historical key order pinned by the stub's deterministic output
ideology: { changes: [] }, // the stub never changes ideologies (see doc comment) // (takeovers → wars → hybrid → economy → industry → nuclear → leaders →
// ideology), which must stay byte-identical.
const abilities = {} as Record<AbilityField, unknown>;
for (const a of ABILITIES) abilities[a.field] = a.stub();
// The single field positioned after the abilities (markers last, as before).
const post: Pick<TurnResult, 'markers'> = {
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment) markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
}; };
return { ...pre, ...abilities, ...post } as TurnResult;
} }
const CANNED_ADVISOR: string[] = [ const CANNED_ADVISOR: string[] = [