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.
154 lines
6.1 KiB
TypeScript
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>();
|
|
}
|