Files
History/server/regions.ts
kamilkrzywda cf7d3cb122 Fix region loader to find the manifest in prod (dist/) — dev-vs-prod path (v0.20.0, completes #16 P1)
v0.19.0's region-level ownership did not activate in the production image:
server/regions.ts loadRegions only looked in public/, but the Docker image keeps
only dist/ (Vite copies public/* -> dist/ at build), so the loader found nothing
and expandCountriesToKeys degraded to a no-op — fresh games kept raw ISO_A3 codes
instead of region keys (the client's own /region-keys.json fetch still worked,
since the client is served from dist/).

loadRegions now probes public/ (dev) then dist/ (prod, == clientDir) and uses the
first directory holding both files. A new test covers the prod-only (dist/), dev
(public/), and empty-root (no data) layouts.
2026-10-14 09:32:29 +00:00

154 lines
6.1 KiB
TypeScript

// 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<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>;
}
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<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);
}
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<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>();
}