refactor(#74) P2: extract shared helpers to server/abilities + define the Ability type surface

Move the 5 shared reference/id primitives (resolveNationRef, resolveRegionKey,
freshNationId, freshWarId, NEW_NATION_STATS) from engine.ts into the new
server/abilities/shared.ts (breaking the engine<->ability import cycle), and
add server/abilities/types.ts with the Ability<K> interface + AbilityCtx /
UserPromptCtx types that the 8 per-feature ability modules will implement.
engine.ts imports the helpers and re-exports them so existing callers
(server/index.ts, the unit tests) are untouched. Behavior is identical; all
511 unit tests, lint and typecheck stay green.
This commit is contained in:
2026-10-16 07:23:11 +00:00
parent fc908aaea9
commit 5cfbce861b
3 changed files with 279 additions and 89 deletions
+10 -89
View File
@@ -12,7 +12,6 @@ import type {
IndustryType,
MapMarker,
Nation,
NationStats,
Preset,
RelationStatus,
RegionResources,
@@ -73,6 +72,16 @@ import {
type WireMessage,
} from './llm';
import { ADVISOR_MARKER } from './stub';
import {
freshNationId,
freshWarId,
NEW_NATION_STATS,
resolveNationRef,
resolveRegionKey,
} from './abilities/shared';
// Re-exported so the existing callers (server/index.ts, the unit tests) keep
// importing these shared primitives from the engine unchanged.
export { freshNationId, freshWarId, NEW_NATION_STATS, resolveNationRef, resolveRegionKey };
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
@@ -2750,94 +2759,6 @@ function validPendingOpIndex(state: GameState, index: number): boolean {
return Number.isInteger(index) && index >= 0 && index < len;
}
/**
* Resolve a nation reference (the `nationRef` of a NationOp — the LLM may
* write either the nation's stable `id` or its human-readable `name`) to a
* concrete nation id: an exact id match wins, then a unique case-insensitive
* name match. Returns `null` when the reference is empty, unknown, or
* ambiguous (two or more nations share that name) — every caller treats
* `null` as "no-op" and nothing here ever throws.
*/
export function resolveNationRef(state: GameState, ref: string): string | null {
const r = (ref ?? '').trim();
if (!r) return null;
if (state.nations[r]) return r; // exact id first
const matches = Object.values(state.nations).filter(
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
);
return matches.length === 1 ? matches[0].id : null;
}
/**
* (v0.29, #46 PR 2) Resolve a **region** reference (a `regionChanges` entry's
* `region` — the LLM may write either the region's stable **key** or its
* human-readable **name**) to a concrete region key: an exact key match wins,
* then a unique case-insensitive name match (via `nameOf`). Returns `null`
* when the reference is empty, unknown, or ambiguous (two or more regions
* share that name) — every caller treats `null` as "drop this change" and
* nothing here ever throws. Mirrors {@link resolveNationRef}. `validKeys` is
* the set of all region keys and `nameOf` maps a key to its human name (both
* supplied by server/regions.ts).
*/
export function resolveRegionKey(
ref: string,
validKeys: ReadonlySet<string>,
nameOf: (key: string) => string | undefined,
): string | null {
const r = (ref ?? '').trim();
if (!r) return null;
if (validKeys.has(r)) return r; // exact key first
const lower = r.toLowerCase();
let match: string | null = null;
for (const k of validKeys) {
const name = nameOf(k);
if (name && name.trim().toLowerCase() === lower) {
if (match !== null) return null; // ambiguous: two or more regions share the name
match = k;
}
}
return match;
}
/**
* Generate a fresh, unused nation id for a newly created nation (split
* parts, v0.24 #16 P4b): the first free of `n1`, `n2`, ... The caller adds
* each id it creates to `taken` as it goes, so ids stay unique within an
* op and are never reused from a removed nation.
*/
export function freshNationId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `n${i}`;
if (!taken.has(id)) return id;
}
}
/**
* Generate a fresh, unused war id for a newly declared {@link War}
* (v0.33.0, #56): the first free of `w1`, `w2`, ... The caller adds each id
* it creates to `taken` as it goes, so ids stay unique within a turn and are
* never reused from an ended war.
*/
export function freshWarId(taken: ReadonlySet<string>): string {
for (let i = 1; ; i++) {
const id = `w${i}`;
if (!taken.has(id)) return id;
}
}
/**
* The baseline stats a freshly created nation starts with (split parts,
* v0.24 #16 P4b) — a neutral middle-of-the-scale profile, deliberately NOT
* a copy of the parent's stats (a small rump state is not the parent's
* military power).
*/
export const NEW_NATION_STATS: NationStats = {
military: 50,
economy: 50,
stability: 50,
population: 50,
};
/**
* Defensive cleanup after nations are removed (merge, v0.24 #16 P4b): drop
* the removed nations' ids from every remaining nation's `relations` map so