- 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).
208 lines
8.7 KiB
TypeScript
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();
|
|
}
|