329 lines
14 KiB
TypeScript
329 lines
14 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) and the
|
||
// precomputed region-centroid manifest (region-centroids.json: region key ->
|
||
// { lat, lon, spread }, built by scripts/build-region-centroids.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)
|
||
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
|
||
// — the data behind LLM-placed map markers (v0.27, #44)
|
||
// - geoOf(): a region key's area (km²) + population (from region-geo.json)
|
||
// — the weighting source for the per-region stats model (v0.28, #46)
|
||
//
|
||
// 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[]>;
|
||
/** region key -> anchor point (from public/region-centroids.json). */
|
||
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
||
/** region key -> { area (km²), population } (from public/region-geo.json, v0.28 #46). */
|
||
geo: Record<string, { area: number; population: number }>;
|
||
}
|
||
|
||
let data: RegionData | null = null;
|
||
|
||
/**
|
||
* Load (or reload) the region-key manifest, geo, adjacency, and centroids.
|
||
* 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). The centroid manifest is likewise
|
||
* OPTIONAL (v0.27, #44): when missing or broken, `centroids` stays empty and
|
||
* `centroidOf`/`hasCentroids` report "no centroids" (new map markers can't be
|
||
* placed, but 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 centroidsFile = 'region-centroids.json';
|
||
const regionGeoFile = 'region-geo.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 geoJson = 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 geoJson.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 = {};
|
||
}
|
||
}
|
||
|
||
// Centroids are optional too (v0.27, #44): a missing/broken file means
|
||
// "no centroid data" — new markers can't be placed (their adds are
|
||
// dropped), but everything else keeps working.
|
||
let centroids: Record<string, { lat: number; lon: number; spread: number }> = {};
|
||
const centroidsPath = path.join(base, centroidsFile);
|
||
if (existsSync(centroidsPath)) {
|
||
try {
|
||
const raw = JSON.parse(readFileSync(centroidsPath, 'utf8')) as Record<
|
||
string,
|
||
{ lat?: unknown; lon?: unknown; spread?: unknown }
|
||
>;
|
||
// Keep only well-formed entries; a corrupt file degrades to "none".
|
||
for (const [k, v] of Object.entries(raw)) {
|
||
if (
|
||
v &&
|
||
typeof v.lat === 'number' &&
|
||
typeof v.lon === 'number' &&
|
||
typeof v.spread === 'number' &&
|
||
Number.isFinite(v.lat) &&
|
||
Number.isFinite(v.lon) &&
|
||
Number.isFinite(v.spread)
|
||
) {
|
||
centroids[k] = { lat: v.lat, lon: v.lon, spread: v.spread };
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[regions] failed to load region-centroids.json, continuing without it', err);
|
||
centroids = {};
|
||
}
|
||
}
|
||
|
||
// The region-geo manifest (v0.28, #46): area (km²) + population per region
|
||
// key — the weighting source for the per-region stats model. OPTIONAL like
|
||
// the others: a missing/broken file means "no geo data", and weighting
|
||
// degrades to uniform (every region weight 1) while everything else keeps
|
||
// working.
|
||
let geo: Record<string, { area: number; population: number }> = {};
|
||
const geoDataPath = path.join(base, regionGeoFile);
|
||
if (existsSync(geoDataPath)) {
|
||
try {
|
||
const raw = JSON.parse(readFileSync(geoDataPath, 'utf8')) as Record<
|
||
string,
|
||
{ area?: unknown; population?: unknown }
|
||
>;
|
||
// Keep only well-formed entries; a corrupt file degrades to "none".
|
||
for (const [k, v] of Object.entries(raw)) {
|
||
if (
|
||
v &&
|
||
typeof v.area === 'number' &&
|
||
Number.isFinite(v.area) &&
|
||
v.area >= 0 &&
|
||
typeof v.population === 'number' &&
|
||
Number.isFinite(v.population) &&
|
||
v.population >= 0
|
||
) {
|
||
geo[k] = { area: v.area, population: v.population };
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[regions] failed to load region-geo.json, continuing without it', err);
|
||
geo = {};
|
||
}
|
||
}
|
||
|
||
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids, geo };
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* True when the region-centroid manifest loaded with at least one entry
|
||
* (v0.27, #44). The server's jump route uses this to decide whether to hand
|
||
* the engine a centroid resolver (which lets a turn place new map markers)
|
||
* or skip marker placement (existing markers are still maintained).
|
||
*/
|
||
export function hasCentroids(): boolean {
|
||
return data !== null && Object.keys(data.centroids).length > 0;
|
||
}
|
||
|
||
/**
|
||
* True when the region-geo manifest loaded with at least one entry (v0.28,
|
||
* #46). The server's jump route uses this to decide whether to hand the
|
||
* engine a geo resolver (which weights the per-region stats derivation by
|
||
* area×population) or fall back to a uniform (unweighted) median.
|
||
*/
|
||
export function hasGeo(): boolean {
|
||
return data !== null && Object.keys(data.geo).length > 0;
|
||
}
|
||
|
||
/**
|
||
* The precomputed anchor point for a region key: the centroid of its
|
||
* largest-area part, plus a `spread` (≈ 0.1 × that part's smallest bbox
|
||
* dimension, in degrees) the engine scatters markers within
|
||
* ±(spread/2) of the centroid. `undefined` for an unknown key or when no
|
||
* centroid data is loaded (v0.27, #44).
|
||
*/
|
||
export function centroidOf(key: string): { lat: number; lon: number; spread: number } | undefined {
|
||
return data?.centroids[key];
|
||
}
|
||
|
||
/**
|
||
* The precomputed area (km²) + population for a region key (v0.28, #46) — the
|
||
* weighting source for the per-region stats model. `undefined` for an unknown
|
||
* key or when no geo data is loaded (the derivation then falls back to a
|
||
* uniform weight of 1 for that region).
|
||
*/
|
||
export function geoOf(key: string): { area: number; population: number } | undefined {
|
||
return data?.geo[key];
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
}
|