Files
History/server/regions.ts
T
kamilkrzywda 5abbebc58f LLM getNeighbours tool + contiguous region transfers (Stage 2 of #16 P2)
- llm.ts: LlmClient.chatWithTools — bounded OpenAI function-calling loop
  (default 6 tool rounds, then one forced no-tools request); shares the
  retry/backoff/reasoning-effort machinery with chat(); tools are only
  added to the request body when non-empty, so plain chat() bodies and
  their tests are unchanged; a tool-call response is a success (never a
  truncation); malformed tool arguments become an error tool-message.
- engine.ts: GET_NEIGHBOURS_TOOL + makeNeighboursExecutor (degrades
  unknown tools/args/keys without throwing); playerFrontier (sorted,
  deduped, other-nation-owned only); buildSystemPrompt gains a tool
  variant (contiguity rules, frontier hint) used only when the tool is
  actually sent; buildUserPrompt emits a frontier field whenever a
  resolver is given; runTurn takes getNeighbours and uses
  chatWithTools when both the resolver and the client's capability are
  present, falling back to plain chat() otherwise.
- applyTurnResult: snapshot-first sole-ownership algorithm — a region
  key transfers only with exactly one claimant and exactly one previous
  owner (different from the gainer); double claims and pre-existing
  double ownership are left untouched; lone losses are no-ops; every
  key has 0 or 2+ -> 1 owner post-turn, never orphaned or duplicated.
- stub.ts: deterministic one-region contiguous conquest per turn from
  the prompt's frontier field (bypasses the tool loop; empty frontier
  -> no transfer, all existing assertions stay stable).
- regions.ts: optional region-adjacency.json load; hasAdjacency() and
  neighboursOf(keys) (union, deduped, queried excluded, sorted).
- index.ts: jump route passes the adjacency resolver when loaded.
- tests: chatWithTools protocol (echo, executor args, bounded rounds,
  malformed args, retry), tool-path runTurn wiring + fallback,
  playerFrontier, executor, applyTurnResult invariants, neighboursOf/
  hasAdjacency (105 unit tests, 14 e2e — all green).
2026-10-14 10:37:25 +00:00

208 lines
8.7 KiB
TypeScript

// Region-key data access (server). Loads the deterministic region-key manifest
// (region-keys.json: stable feature id -> region key), the sub-national geo
// (regions.geo.json: feature id -> { name, parent }), and — optionally — the
// precomputed region-adjacency manifest (region-adjacency.json: region key ->
// bordering region keys, built by scripts/build-region-adjacency.mjs) — all
// from public/ in dev (Vite serves it) or dist/ in prod (Vite copies
// public/* -> dist/ at build, and the Docker image keeps only dist/) — and
// joins them into the lookups the region-level ownership model needs:
//
// - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions (v0.19, #16 P1)
// - build the LLM "regions" reference table (key -> "name (parent ISO_A3)")
// - the set of all valid region keys (to ignore hallucinated keys in applyTurnResult)
// - neighboursOf(): the union of the bordering keys for a set of keys — the
// data behind the LLM's getNeighbours tool and the player-frontier prompt
// field (v0.21, #16 P2)
//
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
// the tsc program and breaks WorldMap.tsx; see the v0.17 changelog note).
// Loaded once at boot; the accessors below are pure data reads thereafter.
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { GameState } from '../shared/types';
/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */
interface RegionInfo {
name: string;
parent: string;
}
interface RegionData {
/** stable feature id -> region key (from public/region-keys.json). */
idToKey: Record<string, string>;
/** region key -> { name, parent }. */
keyToInfo: Record<string, RegionInfo>;
/** ISO_A3 parent -> region keys of its sub-regions (file order, stable). */
keysByCountry: Record<string, string[]>;
/** the set of all region keys. */
validKeys: Set<string>;
/** region key -> bordering region keys (from public/region-adjacency.json). */
adjacency: Record<string, string[]>;
}
let data: RegionData | null = null;
/**
* Load (or reload) the region-key manifest, geo, and adjacency. Idempotent.
* The files are served to the client from `public/` in dev (Vite serves it
* directly) and from `dist/` in prod (Vite copies `public/*` → `dist/` at build
* and the Docker image keeps only `dist/`), so probe `public/` then `dist/`
* (plus the root as a last resort) and use the first directory that holds the
* key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing
* or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply
* report "no adjacency" (the LLM tool and the frontier prompt field switch
* off; everything else keeps working). Returns true when the core pair loaded
* cleanly; a missing/broken pair clears the data and returns false so the
* server still boots and degrades gracefully (expansion becomes a no-op,
* ownership falls back to the raw input keys).
*/
export function loadRegions(root: string): boolean {
const keyFile = 'region-keys.json';
const geoFile = 'regions.geo.json';
const adjacencyFile = 'region-adjacency.json';
const base = [
path.join(root, 'public'), // dev: Vite serves public/ at /
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
root, // last resort
].find((d) => existsSync(path.join(d, keyFile)) && existsSync(path.join(d, geoFile)));
if (!base) {
data = null;
return false;
}
const keyPath = path.join(base, keyFile);
const geoPath = path.join(base, geoFile);
try {
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
};
const keyToInfo: Record<string, RegionInfo> = {};
const keysByCountry: Record<string, string[]> = {};
const validKeys = new Set<string>();
for (const f of geo.features ?? []) {
const id = f.id == null ? '' : String(f.id);
const key = idToKey[id];
if (!key) continue; // a geo feature with no manifest entry is skipped
const name = typeof f.properties?.name === 'string' ? f.properties.name : '';
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
keyToInfo[key] = { name, parent };
validKeys.add(key);
if (parent) (keysByCountry[parent] ??= []).push(key);
}
// Adjacency is optional: a missing file means "no adjacency data".
let adjacency: Record<string, string[]> = {};
const adjacencyPath = path.join(base, adjacencyFile);
if (existsSync(adjacencyPath)) {
try {
adjacency = JSON.parse(readFileSync(adjacencyPath, 'utf8')) as Record<string, string[]>;
} catch (err) {
// A corrupt manifest must not kill the core region data: degrade to
// "no adjacency" (the LLM tool / frontier guidance simply switch off).
console.error('[regions] failed to load region-adjacency.json, continuing without it', err);
adjacency = {};
}
}
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency };
return validKeys.size > 0;
} catch (err) {
console.error('[regions] failed to load region data', err);
data = null;
return false;
}
}
/** Test/dev seam: drop the loaded data (the next accessors behave as "no data"). */
export function resetRegions(): void {
data = null;
}
/** True when the manifest + geo loaded cleanly. */
export function hasRegionData(): boolean {
return data !== null;
}
/**
* Expand a nation's ISO_A3 country list to the region keys of all its sub-regions
* (file order, deduped). This is what turns a preset's country-level territory
* into region-level ownership at game creation. Returns the input unchanged when
* no region data is loaded (graceful degradation — behaves like the old model).
*/
export function expandCountriesToKeys(countries: string[]): string[] {
if (!data) return countries;
const out: string[] = [];
const seen = new Set<string>();
for (const c of countries) {
for (const key of data.keysByCountry[c] ?? []) {
if (!seen.has(key)) {
seen.add(key);
out.push(key);
}
}
}
return out;
}
/**
* Build the LLM reference table: region key -> "<name> (<parent ISO_A3>)", e.g.
* `moselle-rygy -> "Moselle (FRA)"`. Only the given keys are included (the caller
* passes the game's owned-region universe) so the prompt stays bounded.
*/
export function referenceForKeys(keys: Iterable<string>): Record<string, string> {
const out: Record<string, string> = {};
if (!data) return out;
for (const key of keys) {
const info = data.keyToInfo[key];
if (info) out[key] = info.parent ? `${info.name} (${info.parent})` : info.name;
}
return out;
}
/** The region key's human name (for the dossier), or undefined if unknown. */
export function regionName(key: string): string | undefined {
return data?.keyToInfo[key]?.name;
}
/** The union of every nation's territory region keys (the game's region universe). */
export function allTerritoryKeys(state: GameState): Set<string> {
const out = new Set<string>();
for (const n of Object.values(state.nations)) {
for (const key of n.territory) out.add(key);
}
return out;
}
/** The set of all valid region keys (to ignore hallucinated keys in applyTurnResult). */
export function validKeys(): ReadonlySet<string> {
return data ? data.validKeys : new Set<string>();
}
/**
* True when the region-adjacency manifest loaded with at least one entry.
* Callers (the server's jump route) use this to decide whether to hand the
* engine an adjacency resolver (which turns on the LLM's getNeighbours tool
* and the frontier prompt field) or fall back to the plain chat() path.
*/
export function hasAdjacency(): boolean {
return data !== null && Object.keys(data.adjacency).length > 0;
}
/**
* The union of the region keys that share a land border with any of the given
* keys — deduplicated, the queried keys themselves excluded, and sorted for
* determinism. This is the data behind the LLM's getNeighbours tool and the
* player-frontier computation in the turn prompt (v0.21, #16 P2). Returns
* `[]` when no region data (or no adjacency manifest) is loaded.
*/
export function neighboursOf(keys: Iterable<string>): string[] {
if (!data) return [];
const out = new Set<string>();
for (const key of keys) {
for (const n of data.adjacency[key] ?? []) out.add(n);
}
for (const key of keys) out.delete(key); // never report a key as its own neighbour
return [...out].sort();
}