// Sub-national region data: loaded ONCE at server boot from // `public/regions.geo.json` (dev) or `dist/regions.geo.json` (prod — Vite copies // `public/` → `dist/`). It builds two in-memory maps that power the // sub-region-level territory model (v0.19): // // - `regionIndex` : region id → { name, parent } (parent = ISO_A3 country) // - `regionsByCountry`: ISO_A3 country → array of its region ids // // These are used to (a) expand a nation's ISO_A3 territory into its sub-region // ids at game creation / load-time migration, and (b) hand the LLM a human // name for each region id it may reference in a TurnResult. // // This is deliberately turf-free (plain `JSON.parse`): importing `@turf/*` // would pull `@types/geojson` into the tsc program and break `WorldMap.tsx` // (see AGENTS.md / the v0.17 changelog note on the geojson type gotcha). // // The server must still boot and the app still work when the geo file is // missing or unreadable: the maps stay empty, `hasRegionData()` stays false, // and the territory model gracefully degrades to whole-country ISO_A3 codes // (the pre-v0.19 behavior). Nothing here ever throws. import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import type { GameState } from '../shared/types'; /** A region's stable identity: display name + the parent country's ISO_A3. */ export interface RegionInfo { name: string; parent: string; } let regionIndex: Record = {}; let regionsByCountry: Record = {}; let loaded = false; /** True once the region geo has been loaded successfully. */ export function hasRegionData(): boolean { return loaded; } /** * (Re)build the in-memory region maps from the region geo. Idempotent: a second * call is a no-op. Tries `/public/regions.geo.json` first (dev), then * `/dist/regions.geo.json` (prod, where Vite copies `public/` → `dist/`). * If no candidate file is found, or it fails to parse, the maps stay empty and * `hasRegionData()` stays false (graceful fallback to ISO_A3) — never throws. */ export function loadRegions(root: string): void { if (loaded) return; const candidates = [ path.join(root, 'public', 'regions.geo.json'), path.join(root, 'dist', 'regions.geo.json'), ]; const file = candidates.find((c) => existsSync(c)); if (!file) { console.warn( '[history] regions.geo.json not found (tried: ' + candidates.join(', ') + ') — falling back to whole-country (ISO_A3) territory', ); return; } try { const geo = JSON.parse(readFileSync(file, 'utf8')) as { features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string }; }>; }; const index: Record = {}; const byCountry: Record = {}; for (const f of geo.features ?? []) { const id = f.id == null ? '' : String(f.id); const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : ''; const name = typeof f.properties?.name === 'string' ? f.properties.name : ''; if (!id || !parent) continue; index[id] = { name, parent }; (byCountry[parent] ??= []).push(id); } regionIndex = index; regionsByCountry = byCountry; loaded = Object.keys(index).length > 0; } catch (e) { console.warn('[history] failed to load regions.geo.json (' + file + '):', e); } } /** * Expand a list of ISO_A3 country codes into the union of all their sub-region * ids (deduped, preserving first-seen order). When the region data is not * loaded, returns the input unchanged (graceful fallback to ISO_A3 territory). */ export function expandTerritory(countryCodes: string[]): string[] { if (!loaded) return countryCodes; const seen = new Set(); const out: string[] = []; for (const code of countryCodes) { for (const id of regionsByCountry[code] ?? []) { if (!seen.has(id)) { seen.add(id); out.push(id); } } } return out; } /** * A `{ [regionId]: "name (parent)" }` object for the given region ids, for the * LLM prompt. Unknown ids (not in the loaded index) are skipped. Empty when the * region data is not loaded. */ export function regionNamesFor(ids: Iterable): Record { const out: Record = {}; for (const id of ids) { const info = regionIndex[id]; if (info) out[id] = `${info.name} (${info.parent})`; } return out; } /** * Human-readable label for a single region id: `id — name (parent)`, or just * the id when it is unknown (e.g. a region that predates the geo or a * synthetic id). */ export function regionLabel(id: string): string { const info = regionIndex[id]; return info ? `${id} — ${info.name} (${info.parent})` : id; } /** A bare ISO_A3 country code: exactly three uppercase letters (no hyphen). */ const ISO_A3_RE = /^[A-Z]{3}$/; /** * Migrate a loaded game's territory from whole-country ISO_A3 codes to * sub-region ids, for nations that still carry ISO_A3 entries (i.e. games * saved before v0.19). Each such nation's ISO_A3 codes are expanded to all * their sub-region ids; any entries that are already region ids (they contain * a hyphen, so they never match `ISO_A3_RE`) are kept. Idempotent and * no-op when the region data is not loaded or a nation has no ISO_A3 codes. * Mutates and returns the (fresh) state it is given. */ export function migrateTerritoryToRegions(state: GameState): GameState { if (!loaded) return state; for (const n of Object.values(state.nations)) { if (!n.territory.some((t) => ISO_A3_RE.test(t))) continue; const isoCodes = n.territory.filter((t) => ISO_A3_RE.test(t)); const existingRegions = n.territory.filter((t) => !ISO_A3_RE.test(t)); const merged = expandTerritory(isoCodes).concat(existingRegions); const seen = new Set(); const out: string[] = []; for (const id of merged) { if (!seen.has(id)) { seen.add(id); out.push(id); } } n.territory = out; } return state; } // --- Test-only seams --------------------------------------------------------- /** * Test-only: reset the module state to "not loaded" (empty maps, * `hasRegionData()` false). Lets a test exercise the graceful-fallback path. */ export function _resetRegionsForTests(): void { regionIndex = {}; regionsByCountry = {}; loaded = false; } /** * Test-only: inject region maps directly (bypassing the file load) so a unit * test can drive `expandTerritory` / `regionNamesFor` / `regionLabel` / * `migrateTerritoryToRegions` against a small, deterministic fixture without * reading the 3.4 MB geo file. `loaded` becomes true iff `index` is non-empty. */ export function _setRegionsForTests( index: Record, byCountry: Record, ): void { regionIndex = index; regionsByCountry = byCountry; loaded = Object.keys(index).length > 0; }