CI / build + unit + e2e (pull_request) Successful in 2m11s
- scripts/build-region-resources.mjs: deterministic, idempotent builder →
public/region-resources.json (1,800 entries; FNV-1a hash × area factor, 0–1, 2dp)
- server/regions.ts: hasResources()/resourceOf() optional-manifest loader
- shared/types.ts: RegionResources, IndustryType/INDUSTRY_TYPES, IndustryProject,
GameState.industry
- shared/protocol.ts: IndustryOps, TurnResult.industry, deriveNationResources
(area×population-weighted average, 2dp)
- server/engine.ts: isIndustryType, validateIndustryOps, applyIndustryOps
(per-region SET, first-declared-type wins), applyIndustryEffects
(INDUSTRY_ECONOMY_BONUS=1, +1 when region industry≥0.5; sole-owner only;
0/2+ owner regions kept but no boost; re-derive after), system+user prompt
contract lines, runTurn resourceOf opt, nationResources in user payload
- server/db.ts: legacy-row industry default migration
- server/stub.ts: industry:{projects:[]} in stub result; resources/industry
in StubPayload (never read)
- server/index.ts: resourceOf wired to jump route
- All test fixtures updated; new describe blocks in engine.test.ts,
protocol.test.ts, regions.test.ts, db.test.ts
- CHANGELOG.md v0.36.0, AGENTS.md docs, package.json 0.36.0
Fixes #59
403 lines
17 KiB
TypeScript
403 lines
17 KiB
TypeScript
// Region-key data access (server). Loads the deterministic region-key manifest
|
||
// (region-keys.json: stable feature id -> region key), the sub-national geo
|
||
// (regions.geo.json: feature id -> { name, parent }), and — optionally — the
|
||
// precomputed region-adjacency manifest (region-adjacency.json: region key ->
|
||
// bordering region keys, built by scripts/build-region-adjacency.mjs) and the
|
||
// precomputed region-centroid manifest (region-centroids.json: region key ->
|
||
// { lat, lon, spread }, built by scripts/build-region-centroids.mjs) — all
|
||
// 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 needs:
|
||
//
|
||
// - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions (v0.19, #16 P1)
|
||
// - build the LLM "regions" reference table (key -> "name (parent ISO_A3)")
|
||
// - the set of all valid region keys (to ignore hallucinated keys in applyTurnResult)
|
||
// - neighboursOf(): the union of the bordering keys for a set of keys — the
|
||
// data behind the LLM's getNeighbours tool and the player-frontier prompt
|
||
// field (v0.21, #16 P2)
|
||
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
|
||
// — the data behind LLM-placed map markers (v0.27, #44)
|
||
// - geoOf(): a region key's area (km²) + population (from region-geo.json)
|
||
// — the weighting source for the per-region stats model (v0.28, #46)
|
||
// - resourceOf(): a region key's synthesized resource profile (from
|
||
// region-resources.json) — the data behind the LLM's industry lever
|
||
// (v0.36, #59)
|
||
//
|
||
// Deliberately turf-free: it reads the 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, RegionResources } 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>;
|
||
/** region key -> bordering region keys (from public/region-adjacency.json). */
|
||
adjacency: Record<string, string[]>;
|
||
/** region key -> anchor point (from public/region-centroids.json). */
|
||
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
||
/** region key -> { area (km²), population } (from public/region-geo.json, v0.28 #46). */
|
||
geo: Record<string, { area: number; population: number }>;
|
||
/** region key -> resource profile (from public/region-resources.json, v0.36 #59). */
|
||
resources: Record<string, RegionResources>;
|
||
}
|
||
|
||
let data: RegionData | null = null;
|
||
|
||
/**
|
||
* Load (or reload) the region-key manifest, geo, adjacency, and centroids.
|
||
* Idempotent.
|
||
* The 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 the
|
||
* key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing
|
||
* or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply
|
||
* report "no adjacency" (the LLM tool and the frontier prompt field switch
|
||
* off; everything else keeps working). The centroid manifest is likewise
|
||
* OPTIONAL (v0.27, #44): when missing or broken, `centroids` stays empty and
|
||
* `centroidOf`/`hasCentroids` report "no centroids" (new map markers can't be
|
||
* placed, but everything else keeps working). Returns true when the core pair 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 adjacencyFile = 'region-adjacency.json';
|
||
const centroidsFile = 'region-centroids.json';
|
||
const regionGeoFile = 'region-geo.json';
|
||
const regionResourcesFile = 'region-resources.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 geoJson = 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 geoJson.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);
|
||
}
|
||
|
||
// Adjacency is optional: a missing file means "no adjacency data".
|
||
let adjacency: Record<string, string[]> = {};
|
||
const adjacencyPath = path.join(base, adjacencyFile);
|
||
if (existsSync(adjacencyPath)) {
|
||
try {
|
||
adjacency = JSON.parse(readFileSync(adjacencyPath, 'utf8')) as Record<string, string[]>;
|
||
} catch (err) {
|
||
// A corrupt manifest must not kill the core region data: degrade to
|
||
// "no adjacency" (the LLM tool / frontier guidance simply switch off).
|
||
console.error('[regions] failed to load region-adjacency.json, continuing without it', err);
|
||
adjacency = {};
|
||
}
|
||
}
|
||
|
||
// Centroids are optional too (v0.27, #44): a missing/broken file means
|
||
// "no centroid data" — new markers can't be placed (their adds are
|
||
// dropped), but everything else keeps working.
|
||
let centroids: Record<string, { lat: number; lon: number; spread: number }> = {};
|
||
const centroidsPath = path.join(base, centroidsFile);
|
||
if (existsSync(centroidsPath)) {
|
||
try {
|
||
const raw = JSON.parse(readFileSync(centroidsPath, 'utf8')) as Record<
|
||
string,
|
||
{ lat?: unknown; lon?: unknown; spread?: unknown }
|
||
>;
|
||
// Keep only well-formed entries; a corrupt file degrades to "none".
|
||
for (const [k, v] of Object.entries(raw)) {
|
||
if (
|
||
v &&
|
||
typeof v.lat === 'number' &&
|
||
typeof v.lon === 'number' &&
|
||
typeof v.spread === 'number' &&
|
||
Number.isFinite(v.lat) &&
|
||
Number.isFinite(v.lon) &&
|
||
Number.isFinite(v.spread)
|
||
) {
|
||
centroids[k] = { lat: v.lat, lon: v.lon, spread: v.spread };
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[regions] failed to load region-centroids.json, continuing without it', err);
|
||
centroids = {};
|
||
}
|
||
}
|
||
|
||
// The region-geo manifest (v0.28, #46): area (km²) + population per region
|
||
// key — the weighting source for the per-region stats model. OPTIONAL like
|
||
// the others: a missing/broken file means "no geo data", and weighting
|
||
// degrades to uniform (every region weight 1) while everything else keeps
|
||
// working.
|
||
let geo: Record<string, { area: number; population: number }> = {};
|
||
const geoDataPath = path.join(base, regionGeoFile);
|
||
if (existsSync(geoDataPath)) {
|
||
try {
|
||
const raw = JSON.parse(readFileSync(geoDataPath, 'utf8')) as Record<
|
||
string,
|
||
{ area?: unknown; population?: unknown }
|
||
>;
|
||
// Keep only well-formed entries; a corrupt file degrades to "none".
|
||
for (const [k, v] of Object.entries(raw)) {
|
||
if (
|
||
v &&
|
||
typeof v.area === 'number' &&
|
||
Number.isFinite(v.area) &&
|
||
v.area >= 0 &&
|
||
typeof v.population === 'number' &&
|
||
Number.isFinite(v.population) &&
|
||
v.population >= 0
|
||
) {
|
||
geo[k] = { area: v.area, population: v.population };
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[regions] failed to load region-geo.json, continuing without it', err);
|
||
geo = {};
|
||
}
|
||
}
|
||
|
||
// The region-resources manifest (v0.36, #59): a synthesized four-value
|
||
// resource profile per region key — the data behind the LLM's industry
|
||
// lever. OPTIONAL like the others: a missing/broken file means "no
|
||
// resource data" — industry boosts degrade to the unscaled base value and
|
||
// the prompt omits the aggregate profile — while everything else keeps
|
||
// working.
|
||
let resources: Record<string, RegionResources> = {};
|
||
const resourcesPath = path.join(base, regionResourcesFile);
|
||
if (existsSync(resourcesPath)) {
|
||
try {
|
||
const raw = JSON.parse(readFileSync(resourcesPath, 'utf8')) as Record<
|
||
string,
|
||
{ oil?: unknown; iron?: unknown; food?: unknown; industry?: unknown }
|
||
>;
|
||
// Keep only well-formed entries (all four values finite numbers in
|
||
// [0, 1]); a corrupt file degrades to "none".
|
||
for (const [k, v] of Object.entries(raw)) {
|
||
if (
|
||
v &&
|
||
typeof v.oil === 'number' && Number.isFinite(v.oil) && v.oil >= 0 && v.oil <= 1 &&
|
||
typeof v.iron === 'number' && Number.isFinite(v.iron) && v.iron >= 0 && v.iron <= 1 &&
|
||
typeof v.food === 'number' && Number.isFinite(v.food) && v.food >= 0 && v.food <= 1 &&
|
||
typeof v.industry === 'number' && Number.isFinite(v.industry) && v.industry >= 0 && v.industry <= 1
|
||
) {
|
||
resources[k] = { oil: v.oil, iron: v.iron, food: v.food, industry: v.industry };
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[regions] failed to load region-resources.json, continuing without it', err);
|
||
resources = {};
|
||
}
|
||
}
|
||
|
||
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids, geo, resources };
|
||
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>();
|
||
}
|
||
|
||
/**
|
||
* The set of ISO_A3 country codes that own at least one sub-region in the
|
||
* manifest (the valid `territory` codes for a world draft, v0.31 #52). These
|
||
* are the keys of `keysByCountry` — i.e. the distinct `properties.parent`
|
||
* values in regions.geo.json. Empty when no region data is loaded; callers
|
||
* pass it (or `undefined` to skip filtering) to `validateWorldDraft` so a
|
||
* draft's territory is checked against real countries.
|
||
*/
|
||
export function validCountryCodes(): ReadonlySet<string> {
|
||
return data ? new Set(Object.keys(data.keysByCountry)) : new Set<string>();
|
||
}
|
||
|
||
/**
|
||
* True when the region-adjacency manifest loaded with at least one entry.
|
||
* Callers (the server's jump route) use this to decide whether to hand the
|
||
* engine an adjacency resolver (which turns on the LLM's getNeighbours tool
|
||
* and the frontier prompt field) or fall back to the plain chat() path.
|
||
*/
|
||
export function hasAdjacency(): boolean {
|
||
return data !== null && Object.keys(data.adjacency).length > 0;
|
||
}
|
||
|
||
/**
|
||
* True when the region-centroid manifest loaded with at least one entry
|
||
* (v0.27, #44). The server's jump route uses this to decide whether to hand
|
||
* the engine a centroid resolver (which lets a turn place new map markers)
|
||
* or skip marker placement (existing markers are still maintained).
|
||
*/
|
||
export function hasCentroids(): boolean {
|
||
return data !== null && Object.keys(data.centroids).length > 0;
|
||
}
|
||
|
||
/**
|
||
* True when the region-geo manifest loaded with at least one entry (v0.28,
|
||
* #46). The server's jump route uses this to decide whether to hand the
|
||
* engine a geo resolver (which weights the per-region stats derivation by
|
||
* area×population) or fall back to a uniform (unweighted) median.
|
||
*/
|
||
export function hasGeo(): boolean {
|
||
return data !== null && Object.keys(data.geo).length > 0;
|
||
}
|
||
|
||
/**
|
||
* The precomputed anchor point for a region key: the centroid of its
|
||
* largest-area part, plus a `spread` (≈ 0.1 × that part's smallest bbox
|
||
* dimension, in degrees) the engine scatters markers within
|
||
* ±(spread/2) of the centroid. `undefined` for an unknown key or when no
|
||
* centroid data is loaded (v0.27, #44).
|
||
*/
|
||
export function centroidOf(key: string): { lat: number; lon: number; spread: number } | undefined {
|
||
return data?.centroids[key];
|
||
}
|
||
|
||
/**
|
||
* The precomputed area (km²) + population for a region key (v0.28, #46) — the
|
||
* weighting source for the per-region stats model. `undefined` for an unknown
|
||
* key or when no geo data is loaded (the derivation then falls back to a
|
||
* uniform weight of 1 for that region).
|
||
*/
|
||
export function geoOf(key: string): { area: number; population: number } | undefined {
|
||
return data?.geo[key];
|
||
}
|
||
|
||
/**
|
||
* True when the region-resources manifest loaded with at least one entry
|
||
* (v0.36, #59). The server's jump route uses this to decide whether to hand
|
||
* the engine a resource resolver (which lets a turn scale industry boosts by a
|
||
* region's `industry` value and carry each nation's aggregate resource profile
|
||
* in the LLM prompt) or skip both (industry boosts fall back to the unscaled
|
||
* base value).
|
||
*/
|
||
export function hasResources(): boolean {
|
||
return data !== null && Object.keys(data.resources).length > 0;
|
||
}
|
||
|
||
/**
|
||
* The synthesized resource profile for a region key (v0.36, #59): the
|
||
* four-value `{ oil, iron, food, industry }` from the committed
|
||
* `public/region-resources.json`. `undefined` for an unknown key or when no
|
||
* resource data is loaded (industry boosts then use the unscaled base value,
|
||
* and the aggregate profile is simply omitted from the prompt).
|
||
*/
|
||
export function resourceOf(key: string): RegionResources | undefined {
|
||
return data?.resources[key];
|
||
}
|
||
|
||
/**
|
||
* The union of the region keys that share a land border with any of the given
|
||
* keys — deduplicated, the queried keys themselves excluded, and sorted for
|
||
* determinism. This is the data behind the LLM's getNeighbours tool and the
|
||
* player-frontier computation in the turn prompt (v0.21, #16 P2). Returns
|
||
* `[]` when no region data (or no adjacency manifest) is loaded.
|
||
*/
|
||
export function neighboursOf(keys: Iterable<string>): string[] {
|
||
if (!data) return [];
|
||
const out = new Set<string>();
|
||
for (const key of keys) {
|
||
for (const n of data.adjacency[key] ?? []) out.add(n);
|
||
}
|
||
for (const key of keys) out.delete(key); // never report a key as its own neighbour
|
||
return [...out].sort();
|
||
}
|