The single source of truth now keys ownership by region key (one per owned sub-region) instead of a parent-country ISO_A3: - shared/types.ts, shared/protocol.ts: TerritoryChange / RegionMap / regionOwner doc flips; mapFeatureOwner now reads the feature's stamped properties.regionKey (never the repacked numeric feature.id, nor the sub-region id — the #6 guard). - server/regions.ts (new, turf-free): loads region-keys.json + regions.geo.json once at boot; expands a nation's ISO_A3 list to its sub-regions' keys, builds the LLM 'regions' reference table (key -> "name (parent)"), and exposes the set of valid keys. Degrades gracefully when the files are missing. - server/engine.ts: buildSystemPrompt instructs the model to use region keys; buildUserPrompt gains an optional regionReference table; applyTurnResult gains an optional validKeys set so hallucinated keys are ignored. - server/index.ts: loads region data at boot; expands each nation's country list to region keys at game creation; the jump route passes the reference + valid keys through runTurn. The stub is unchanged (it emits an empty, region-key-conformant territoryChanges).
141 lines
5.4 KiB
TypeScript
141 lines
5.4 KiB
TypeScript
// Region-key data access (server). Loads the deterministic region-key manifest
|
|
// (public/region-keys.json: stable feature id -> region key) and the sub-national
|
|
// geo (public/regions.geo.json: feature id -> { name, parent }), then 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 from `root`. Idempotent.
|
|
* Returns true when both files loaded cleanly. A missing/broken file 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 keyPath = path.join(root, 'public', 'region-keys.json');
|
|
const geoPath = path.join(root, 'public', 'regions.geo.json');
|
|
if (!existsSync(keyPath) || !existsSync(geoPath)) {
|
|
data = null;
|
|
return false;
|
|
}
|
|
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>();
|
|
}
|