// Shared reference-resolution / id-generation primitives used across the // modular LLM abilities (and re-exported by the core engine). Living here — // and in `./types.ts` rather than in `engine.ts` — breaks the engine <-> // ability import cycle: the ability modules import from here, `engine.ts` // imports + re-exports them for the existing callers (`server/index.ts`, the // unit tests), and no ability imports the engine. Pure and dependency-free. import type { GameState, NationStats } from '../../shared/types'; /** * Resolve a nation reference (the `nationRef` of a NationOp, or the `target` / * `from` / `sides` of an ability op — 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 region op's `region` / * `regionKey` — 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, 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 { 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 { 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, }; /** * Coerce an unknown LLM JSON value to a trimmed, non-empty string, or `null` * when it is not a non-empty string. An optional `max` caps the length (used * for the 200-char caps on nationOp fields). Shared by every defensive * validator (engine + the ability modules). Never throws. */ export function str(s: unknown, max = 0): string | null { if (typeof s !== 'string') return null; const t = s.trim(); if (!t) return null; return max ? t.slice(0, max) : t; }