diff --git a/CHANGELOG.md b/CHANGELOG.md index b8da7fd..1822dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ 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 - **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** — `-<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 → " (parent ISO_A3)"), and `applyTurnResult` ignores any territory key not in the valid set, so a hallucinated key can't corrupt ownership. diff --git a/package.json b/package.json index 75567d2..d4dc538 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.19.0", + "version": "0.20.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/regions.test.ts b/server/regions.test.ts index 0859ed6..05ce09d 100644 --- a/server/regions.test.ts +++ b/server/regions.test.ts @@ -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 { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { allTerritoryKeys, expandCountriesToKeys, @@ -101,3 +104,50 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => { 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); + }); +}); diff --git a/server/regions.ts b/server/regions.ts index e5ab90a..9774a5d 100644 --- a/server/regions.ts +++ b/server/regions.ts @@ -1,7 +1,9 @@ // 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: +// (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)") @@ -35,18 +37,29 @@ interface RegionData { 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). + * 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 keyPath = path.join(root, 'public', 'region-keys.json'); - const geoPath = path.join(root, 'public', 'regions.geo.json'); - if (!existsSync(keyPath) || !existsSync(geoPath)) { + 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; const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {