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:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+29
-9
@@ -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[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user