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).
This commit is contained in:
+72
-18
@@ -1,18 +1,23 @@
|
||||
// Region-key data access (server). Loads the deterministic region-key manifest
|
||||
// (region-keys.json: stable feature id -> region key) and the sub-national geo
|
||||
// (regions.geo.json: feature id -> { name, parent }) — 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 (v0.19, #16 P1) needs:
|
||||
// (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
|
||||
// - 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 two 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.
|
||||
// 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';
|
||||
@@ -32,23 +37,30 @@ interface RegionData {
|
||||
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. Idempotent. The two 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 both files. Returns true when
|
||||
* both 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,
|
||||
* 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)
|
||||
@@ -78,7 +90,22 @@ export function loadRegions(root: string): boolean {
|
||||
validKeys.add(key);
|
||||
if (parent) (keysByCountry[parent] ??= []).push(key);
|
||||
}
|
||||
data = { idToKey, keyToInfo, keysByCountry, validKeys };
|
||||
|
||||
// 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);
|
||||
@@ -151,3 +178,30 @@ export function allTerritoryKeys(state: GameState): Set<string> {
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user