// 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: // // - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions // - build the LLM "regions" reference table (key -> "name (parent ISO_A3)") // - the set of all valid region keys (to ignore hallucinated keys in applyTurnResult) // // 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. 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; /** region key -> { name, parent }. */ keyToInfo: Record; /** ISO_A3 parent -> region keys of its sub-regions (file order, stable). */ keysByCountry: Record; /** the set of all region keys. */ validKeys: Set; } 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, * 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 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; const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as { features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>; }; const keyToInfo: Record = {}; const keysByCountry: Record = {}; const validKeys = new Set(); 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); } data = { idToKey, keyToInfo, keysByCountry, validKeys }; 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(); 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 -> " ()", 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): Record { const out: Record = {}; 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 { const out = new Set(); 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 { return data ? data.validKeys : new Set(); }