v0.40.0 — Modular LLM-ability system (ordered registry) #75

Merged
kamilkrzywda merged 6 commits from refactor/ability-modules into master 2026-10-16 09:27:43 +02:00
4 changed files with 129 additions and 1612 deletions
Showing only changes of commit 15a1642eec - Show all commits
+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
* `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
+15 -11
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import Database from 'better-sqlite3';
import { migrateRegionStats } from '../shared/protocol';
import type { GameState } from '../shared/types';
import { ABILITIES } from './abilities';
export interface Db {
save(state: GameState): void;
@@ -49,17 +50,20 @@ export function openDb(filePath: string): Db {
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
// (v0.27, #44) Same for map markers: a legacy game has none placed.
if (state.markers === undefined) state.markers = [];
// (v0.33.0, #56) Same for named wars: a legacy game has none declared
// (any pre-v0.33 `war` relation stays a bare/implicit relation — see
// syncWarRelations, which never force-creates a War entity).
if (state.wars === undefined) state.wars = [];
// (v0.35.0, #58) Same for the trade & embargo economy layer: a
// legacy game has no active trades or embargoes.
if (state.trades === undefined) state.trades = [];
if (state.embargoes === undefined) state.embargoes = [];
// (v0.36, #59) Same for the industry layer: a legacy game has no
// active industry projects.
if (state.industry === undefined) state.industry = [];
// (v0.33.0 #56, v0.35.0 #58, v0.36 #59, v0.40.0 #67) Same for every
// ability-owned GameState field (wars / trades / embargoes / industry):
// a legacy game has none of them. The ABILITIES registry is the single
// source of truth for which fields the LLM abilities own — each is a
// list, so the default is `[]`, applied only-if-undefined so a saved
// value is never wiped. (Any pre-v0.33 `war` relation stays a
// bare/implicit relation — see syncWarRelations, which never
// force-creates a War entity.)
for (const a of ABILITIES) {
const fields = a.stateFields;
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
// 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
+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 { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
import type { RelationStatus, RoundItem } from '../shared/types';
import { ABILITIES, type AbilityField } from './abilities';
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),
events,
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
@@ -238,16 +250,24 @@ export function deterministicTurn(p: StubPayload): TurnResult {
territoryChanges,
unowned: [], // the stub never creates no man's land (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)
economy: { trade: [], embargo: [] }, // the stub never touches trades/embargoes (see doc comment)
industry: { projects: [] }, // the stub never declares industry projects (see doc comment)
nuclear: { arm: [], strikes: [] }, // the stub never arms or strikes (see doc comment)
leaders: { changes: [] }, // the stub never changes leaders (see doc comment)
ideology: { changes: [] }, // the stub never changes ideologies (see doc comment)
};
// The 8 ability fields, filled by the registry in its canonical order. The
// stub is ability-inert: every ability contributes its own no-op default
// (each ability's `stub()` — see the per-ability doc comments above), so the
// e2e assertions are unaffected. The insertion order here (ABILITIES) is the
// historical key order pinned by the stub's deterministic output
// (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)
};
return { ...pre, ...abilities, ...post } as TurnResult;
}
const CANNED_ADVISOR: string[] = [