Fix: region loader finds the manifest in prod (dist/) — completes P1 region ownership (v0.20.0) #35
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## v0.20.0 — 2026-10-14 09:30
|
||||||
|
- **Fixed: v0.19.0's region-level ownership did not activate in the production image.** The server-side region loader (`server/regions.ts`) only looked for the manifest + geo in `public/`, but in the Docker image Vite copies `public/*` → `dist/` and no `public/` directory is kept — so `loadRegions` found nothing, `expandCountriesToKeys` degraded to a no-op, and freshly-created games kept raw ISO_A3 codes (`DEU`, `GBR,IRL,IND`, …) instead of expanding to region keys. The choropleth therefore still rendered whole-country ownership and the model's region-key contract was inactive in prod (the client's own `/region-keys.json` fetch still worked, because the client is served from `dist/`). `loadRegions` now probes `public/` (dev) then `dist/` (prod, == `clientDir`) and uses the first directory holding both files, so one build works in both environments. A new test covers the prod-only (`dist/`), dev (`public/`), and empty-root (no data) layouts.
|
||||||
|
- **Deploy:** `docker compose down -v` (drops the `history-data` volume — pre-v0.19 saves were already dropped by v0.19.0 and are dropped again here) then `up -d --build`.
|
||||||
|
|
||||||
## v0.19.0 — 2026-10-14 09:14
|
## v0.19.0 — 2026-10-14 09:14
|
||||||
- **Region-level ownership foundation (Part 1 of #16): internal territory ownership flips from whole countries to individual sub-regions.** Previously a nation owned *whole countries* (a `territory` list of ISO_A3 codes) and every sub-region of a country shared the parent's color, so a single region could never change owner independently. Now each owned sub-region is addressed by a stable **region key** — `<name-slug>-<4char>`, e.g. `moselle-rygy`, `ain-lqtt`, `western-alun` — generated deterministically by `scripts/build-region-keys.mjs` into `public/region-keys.json` (**1,796 unique keys** over the 1,796 sub-regions). A key is a slug of the region's name (NFKD, diacritics stripped, lowercased, non-alphanumerics → `-`) plus a 4-character hash of the feature's stable id, salted on within-country name collisions, so it is stable, human-readable, and globally unique (a "western" collision group yields `western-alun` / `western-qwvr` / `western-adsp` / …; an accented name like Shkodër becomes `shkoder-gmnr`).
|
- **Region-level ownership foundation (Part 1 of #16): internal territory ownership flips from whole countries to individual sub-regions.** Previously a nation owned *whole countries* (a `territory` list of ISO_A3 codes) and every sub-region of a country shared the parent's color, so a single region could never change owner independently. Now each owned sub-region is addressed by a stable **region key** — `<name-slug>-<4char>`, e.g. `moselle-rygy`, `ain-lqtt`, `western-alun` — generated deterministically by `scripts/build-region-keys.mjs` into `public/region-keys.json` (**1,796 unique keys** over the 1,796 sub-regions). A key is a slug of the region's name (NFKD, diacritics stripped, lowercased, non-alphanumerics → `-`) plus a 4-character hash of the feature's stable id, salted on within-country name collisions, so it is stable, human-readable, and globally unique (a "western" collision group yields `western-alun` / `western-qwvr` / `western-adsp` / …; an accented name like Shkodër becomes `shkoder-gmnr`).
|
||||||
- **The map, the model, and the LLM all now reason in region keys.** `Nation.territory` and the `RegionMap` are keyed by region key; the choropleth resolves each sub-region's owner by its stamped `properties.regionKey` (never the repacked numeric feature `id`, never the raw sub-region id — the #6 guard, now region-keyed). A new `server/regions.ts` (turf-free) loads the manifest + geo once at boot and **expands each nation's country list to all its sub-regions' keys at game creation**, so a fresh game shows every sub-region in its owner's color — visually identical to the old whole-country choropleth, but each sub-region is now individually addressable. The LLM prompt gains a `regions` reference table (region key → "<name> (parent ISO_A3)"), and `applyTurnResult` ignores any territory key not in the valid set, so a hallucinated key can't corrupt ownership.
|
- **The map, the model, and the LLM all now reason in region keys.** `Nation.territory` and the `RegionMap` are keyed by region key; the choropleth resolves each sub-region's owner by its stamped `properties.regionKey` (never the repacked numeric feature `id`, never the raw sub-region id — the #6 guard, now region-keyed). A new `server/regions.ts` (turf-free) loads the manifest + geo once at boot and **expands each nation's country list to all its sub-regions' keys at game creation**, so a fresh game shows every sub-region in its owner's color — visually identical to the old whole-country choropleth, but each sub-region is now individually addressable. The LLM prompt gains a `regions` reference table (region key → "<name> (parent ISO_A3)"), and `applyTurnResult` ignores any territory key not in the valid set, so a hallucinated key can't corrupt ownership.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.19.0",
|
"version": "0.20.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
|
|||||||
+51
-1
@@ -1,5 +1,8 @@
|
|||||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
allTerritoryKeys,
|
allTerritoryKeys,
|
||||||
expandCountriesToKeys,
|
expandCountriesToKeys,
|
||||||
@@ -101,3 +104,50 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
|||||||
|
|
||||||
afterAll(() => resetRegions());
|
afterAll(() => resetRegions());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadRegions resolves the manifest per environment (dev public/ vs prod dist/)', () => {
|
||||||
|
const realRoot = root;
|
||||||
|
// A root that mimics the prod Docker image: no public/, the region files live
|
||||||
|
// in dist/ (where Vite copies public/* at build). This is the exact layout that
|
||||||
|
// broke v0.19.0 in prod (the loader only looked in public/).
|
||||||
|
const base = mkdtempSync(path.join(tmpdir(), 'history-regions-'));
|
||||||
|
const prodRoot = path.join(base, 'prod');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mkdirSync(path.join(prodRoot, 'dist'), { recursive: true });
|
||||||
|
copyFileSync(
|
||||||
|
path.join(realRoot, 'public', 'region-keys.json'),
|
||||||
|
path.join(prodRoot, 'dist', 'region-keys.json'),
|
||||||
|
);
|
||||||
|
copyFileSync(
|
||||||
|
path.join(realRoot, 'public', 'regions.geo.json'),
|
||||||
|
path.join(prodRoot, 'dist', 'regions.geo.json'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => resetRegions());
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(base, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads from dist/ when public/ is absent (the prod container layout)', () => {
|
||||||
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
|
expect(hasRegionData()).toBe(true);
|
||||||
|
expect(validKeys().size).toBe(1796);
|
||||||
|
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still loads from public/ (the dev layout, the first candidate)', () => {
|
||||||
|
expect(loadRegions(realRoot)).toBe(true);
|
||||||
|
expect(validKeys().size).toBe(1796);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
|
||||||
|
const emptyRoot = path.join(base, 'empty');
|
||||||
|
mkdirSync(emptyRoot, { recursive: true });
|
||||||
|
expect(loadRegions(emptyRoot)).toBe(false);
|
||||||
|
expect(hasRegionData()).toBe(false);
|
||||||
|
expect(validKeys().size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+23
-10
@@ -1,7 +1,9 @@
|
|||||||
// Region-key data access (server). Loads the deterministic region-key manifest
|
// Region-key data access (server). Loads the deterministic region-key manifest
|
||||||
// (public/region-keys.json: stable feature id -> region key) and the sub-national
|
// (region-keys.json: stable feature id -> region key) and the sub-national geo
|
||||||
// geo (public/regions.geo.json: feature id -> { name, parent }), then joins them
|
// (regions.geo.json: feature id -> { name, parent }) — from public/ in dev (Vite
|
||||||
// into the lookups the region-level ownership model (v0.19, #16 P1) needs:
|
// 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
|
// - 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)")
|
// - build the LLM "regions" reference table (key -> "name (parent ISO_A3)")
|
||||||
@@ -35,18 +37,29 @@ interface RegionData {
|
|||||||
let data: RegionData | null = null;
|
let data: RegionData | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load (or reload) the region-key manifest + geo from `root`. Idempotent.
|
* Load (or reload) the region-key manifest + geo. Idempotent. The two files are
|
||||||
* Returns true when both files loaded cleanly. A missing/broken file clears the
|
* served to the client from `public/` in dev (Vite serves it directly) and from
|
||||||
* data and returns false, so the server still boots and degrades gracefully
|
* `dist/` in prod (Vite copies `public/*` → `dist/` at build and the Docker image
|
||||||
* (expansion becomes a no-op, ownership falls back to the raw input keys).
|
* 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 {
|
export function loadRegions(root: string): boolean {
|
||||||
const keyPath = path.join(root, 'public', 'region-keys.json');
|
const keyFile = 'region-keys.json';
|
||||||
const geoPath = path.join(root, 'public', 'regions.geo.json');
|
const geoFile = 'regions.geo.json';
|
||||||
if (!existsSync(keyPath) || !existsSync(geoPath)) {
|
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;
|
data = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const keyPath = path.join(base, keyFile);
|
||||||
|
const geoPath = path.join(base, geoFile);
|
||||||
try {
|
try {
|
||||||
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
|
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
|
||||||
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
|
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
|
||||||
|
|||||||
Reference in New Issue
Block a user