feat: sub-region-level territory — ownership, LLM and map at region granularity (v0.19.0)
Move territory from whole-country (ISO_A3) to individual sub-region id granularity (PR 3 of #16): - new server/regions.ts (turf-free): loadRegions (boot), expandTerritory (game creation), migrateTerritoryToRegions (legacy games on db.load), regionNamesFor / regionLabel (LLM prompt), graceful ISO_A3 fallback - game creation expands each preset nation's ISO_A3 list to its sub-region ids; pre-v0.19 saved games migrate transparently on read (idempotent) - LLM contract: territoryChanges gain/lose sub-region ids (one region at a time; gain moves out of the prior owner; lost-without-gainer = unowned); user prompt carries a top-level regions reference table ({id: 'name (parent)'} over the state's territory ids) - WorldMap: choropleth fill per sub-region (countries can split between nations), ownership via properties.regionId through the shared mapFeatureOwner, flash lights exactly the changed features (featuresByRegionIds) - deterministic stub: one region-level move per non-empty round (player annexes the lexicographically-first region of the first other nation) - tests: new server/regions.test.ts (real geo + fallback + seams); map/engine tests re-pinned to region ids; scripts/regions.test.ts stays green Measured full wwii-1939 user prompt (411 regions): ~24.7k chars / ~6.2k tokens — far under the 80k frontier limit, so the full regions table is always sent (no frontier-limited mode needed). Part of #16
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
// 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<string, RegionInfo> = {};
|
||||
let regionsByCountry: Record<string, string[]> = {};
|
||||
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 `<root>/public/regions.geo.json` first (dev), then
|
||||
* `<root>/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<string, RegionInfo> = {};
|
||||
const byCountry: Record<string, string[]> = {};
|
||||
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<string>();
|
||||
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<string>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<string>();
|
||||
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<string, RegionInfo>,
|
||||
byCountry: Record<string, string[]>,
|
||||
): void {
|
||||
regionIndex = index;
|
||||
regionsByCountry = byCountry;
|
||||
loaded = Object.keys(index).length > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user