Compare commits

..
Author SHA1 Message Date
kamilkrzywda b9ac79c57f Bump to v0.19.0: region-level ownership (Part 1 of #16)
CI / build + unit + e2e (pull_request) Successful in 1m36s
Version 0.19.0 + changelog. Breaking: pre-v0.19 saved games are dropped
(territory now holds region keys, not ISO_A3 codes) — deploy wipes the
history-data volume.
2026-10-14 09:15:33 +00:00
kamilkrzywda 1c64aa1da4 Tests: region-key contract (map rewrite + regions loader + engine) (v0.19 P1 of #16)
- map.test.ts: rewrite the fixture for region keys; the #6 regression guard now
  proves the owner resolves via properties.regionKey while the repacked numeric
  feature.id and the sub-region regionId do not.
- regions.test.ts (new): the loader sees 1,796 valid keys; FRA expands to its
  42 sub-region keys (all valid, all labelled '… (FRA)'); allTerritoryKeys
  unions correctly; and with no data it degrades to the raw input.
- engine.test.ts: buildUserPrompt includes a regions table only when provided;
  applyTurnResult with a validKeys set applies real keys and ignores bogus ones,
  and without one applies all (the legacy/unit path).
2026-10-14 09:14:24 +00:00
kamilkrzywda 29927bae34 Flip the client to region-key resolution + region names in the dossier (v0.19 P1 of #16)
- src/lib/regions.ts (new): cached, turf-free same-origin fetches of
  /region-keys.json (feature id -> key) and /regions.geo.json (id -> name) so
  WorldMap and NationPanel share one fetch each.
- WorldMap.tsx: stamps each feature's properties.regionKey (from the manifest,
  by its stable id) and resolves owner / flash / hover / click from that key via
  the shared mapFeatureOwner; the per-region flash lights the exact sub-region
  whose owner changed; click carries only the sub-region name.
- NationPanel.tsx: territory chips show the region's human name (falling back
  to the raw key) with the key in the title / data-attr.
- App.tsx: the selected-region dossier label drops the now-unused parent code.
2026-10-14 09:14:17 +00:00
kamilkrzywda 325bdc5255 Flip ownership to region keys: shared contract + server (v0.19 P1 of #16)
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).
2026-10-14 09:14:10 +00:00
kamilkrzywda 2cfa2f78f9 Add region-keys.test.ts: 1,796 unique <slug>-<4char> keys, geo-id join (v0.19 P1 of #16) 2026-10-14 08:32:36 +00:00
kamilkrzywda 2b84c34f41 Add region-key manifest (build:region-keys) + public/region-keys.json (v0.19 P1 of #16)
Deterministic <name-slug>-<4char> key per sub-region: name via NFKD strip +
fold table (ə ø ð đ œ ł) + a-z0-9 collapse; 4-char from FNV-1a of the stable
feature id, salted on within-slug collision so all 1,796 keys are unique.
Builtins only (no @turf). Idempotent (byte-identical on re-run).
2026-10-14 08:32:36 +00:00
20 changed files with 2581 additions and 665 deletions
+7 -9
View File
@@ -2,15 +2,13 @@
All notable changes to **History** are listed here, newest first.
## v0.19.0 — 2026-10-13 22:02
- **Sub-region-level territory (PR 3 of #16): ownership, the LLM, and the map all operate at individual sub-region granularity instead of whole-country.**
- `Nation.territory` is now a list of **sub-region ids** (`FR-57`, `DE-BB`, `US-AK`, … — the feature ids of `regions.geo.json`) rather than ISO_A3 country codes. At game creation (`POST /api/games`) each preset nation's ISO_A3 list is expanded to all its sub-region ids by the new `server/regions.ts` (a turf-free module that loads `public/`/`dist/regions.geo.json` once at boot into a region-id index + a country→regions map). Games saved before v0.19 are transparently migrated to region-level territory on read (`db.load` → `migrateTerritoryToRegions` — idempotent, and a no-op when the geo is not loaded).
- **The LLM contract follows the map:** a `TurnResult`'s `territoryChanges` now gain/lose sub-region ids, so a border can shift **one region at a time** — a gained region is moved out of whichever nation currently holds it, and a lost region with no matching gainer becomes unowned no-man's-land (the `applyTurnResult` splice logic was already format-agnostic and is unchanged). The user prompt carries a new top-level `regions` reference table — `{ id: "name (parent)" }` for every region id present in the world state — so the model can map an id it is considering to a real place; the system prompt's hard constraints now name sub-region ids (e.g. `FR-57`). **Prompt-size measurement (full `wwii-1939`: 7 nations, 411 regions): the complete user prompt is ≈ 24.7k chars / ≈ 6.2k tokens at start (≈ 26.4k / ≈ 6.6k with 8 recent events + a 2-item round) — far under the ~80k frontier limit, so the full `regions` table is always sent (no frontier-limited mode was needed).**
- **The choropleth is now truly per-region:** a nation's color fills exactly the sub-regions it owns, so a single country can be visibly split between nations, and unowned sub-regions render grey. Click/hover ownership resolves via the feature's `properties.regionId` (now the RegionMap key) through the shared `mapFeatureOwner` in `shared/protocol`, and the territory-change flash lights up exactly the regions whose owner changed (`featuresByRegionIds`, replacing the country-expanding `featuresByParents`).
- **Graceful degradation:** when the geo file is missing or unreadable the region maps stay empty and `hasRegionData()` is false, so expansion, migration, the prompt's `regions` table, and ownership resolution all fall back to the pre-v0.19 whole-country ISO_A3 behavior — the server still boots and the app still works (with a one-time console warning).
- **The deterministic stub stays deterministic** and now also makes exactly one region-level move per non-empty round: the player annexes the lexicographically-first sub-region of the first other nation (by key order) — empty rounds make no move — so unit tests and e2e assert the exact region that changes hands.
- **Tests:** new `server/regions.test.ts` (real-geo expansion/migration, the not-loaded fallback path, and the fixture seams); `server/map.test.ts` and `server/engine.test.ts` re-pinned to region-id fixtures, including the #6 regression guard (owner from `properties.regionId`, never the repacked numeric `feature.id`) and new `applyTurnResult` tests for prior-owner transfer, no-owner gain, and no-gainer lose.
- Part of #16
## 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`).
- **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 dossier shows region names.** NationPanel's territory chips display each sub-region's human name (e.g. "Ain", "Wales", "Moselle") with the raw key in the tooltip / data attribute, instead of bare codes.
- **⚠ Breaking — pre-v0.19 saved games are dropped.** The `territory` field's meaning changed from ISO_A3 country codes to region keys, so a game saved before this version can't be read by it. Deploy runs `docker compose down -v` (dropping the `history-data` volume) then `up -d --build`; there is no migration — start fresh after deploying.
- **Supersedes the abandoned `feat/sub-region-territory` branch**, which prototyped raw feature-id keys (`DEU-03`); this version uses the slugged, human-readable key scheme instead.
- **Verification.** Typecheck, lint, and the unit suite (now **71 tests across 7 files**: +`server/regions.test.ts`, a rewritten `server/map.test.ts`, +2 in `engine.test.ts`) all pass; the production build is clean. The deterministic `LLM_STUB` and the format-agnostic e2e suite (legend/stats/dates) are unchanged. **#16 stays open** — P2 (LLM tools / function-calling for reliable region ops) and the no-man's-land rename/split/merge phases follow.
## v0.18.0 — 2026-10-13 18:54
- **Fixed a stale-owner display bug: the region hover tooltip and the dossier "Selected" label no longer print the static parent-country code (e.g. "FRA").** The GeoJSON `properties.parent` is a *static geographic-origin* field that never updates when a country is taken, yet it was rendered in the hover tooltip (as a sub-line and as a title fallback) and in the dossier's `Selected:` label (as a `(FRA)` suffix) — so a sub-region of a transferred country still displayed the old country's code next to the (correct) new owner. The tooltip now shows only the region name + the live owner chip (from the RegionMap) + key stats, and the dossier label shows only the region name. Ownership resolution still keys on `properties.parent` (unchanged) — only the display of it was removed.
+1
View File
@@ -14,6 +14,7 @@
"cleanup:regions": "node scripts/cleanup-regions.mjs",
"smooth:regions": "node scripts/smooth-regions.mjs",
"fill:regions": "node scripts/fill-gaps.mjs",
"build:region-keys": "node scripts/build-region-keys.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"e2e": "playwright test"
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
/**
* scripts/build-region-keys.mjs — build the deterministic region-key manifest.
*
* Region ownership flips from whole-country (ISO_A3) to individual sub-region
* granularity, but the LLM and the persisted `Nation.territory` list need a
* *stable, human-readable, globally unique* identifier per sub-region. Raw
* feature ids (`FR-57`) are opaque to the model, so we mint a readable key of
* the form:
*
* <name-slug>-<4char> e.g. brittany-xk2a, western-alun
*
* where:
* - <name-slug> = properties.name → NFKD → strip combining marks → lowercase
* → a tiny fold table for the handful of letters NFKD does NOT decompose
* (ə ø ð đ œ ł, the only non-folding letters present in this dataset)
* → every non-alphanumeric run collapsed to a single '-' → leading/trailing
* '-' trimmed.
* - <4char> = a deterministic 4-letter lowercase code derived from the
* feature's stable id (FNV-1a 32-bit → 4 base-26 chars). It is stable
* (same id → same code on every run) because the key is persisted in
* SQLite and shown to the LLM. Within any name-slug group (e.g. the six
* regions all named "Western") the 4-char codes are made distinct by
* re-deriving with a deterministic salt on collision, which guarantees the
* full key is unique across all 1,796 regions.
*
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map
* is looked up by the client (`WorldMap`) and the server (`map`/`presets`/
* `engine`) to join a sub-region's stable id to its readable key.
*
* Builtins only — deliberately NO `@turf`/geojson import (importing one pulls
* `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see the
* v0.17 changelog note). Reads the geo file with plain `JSON.parse`.
*
* Idempotent: a re-run rewrites a byte-identical file (no timestamps, no
* randomness, stable feature order, deterministic hash).
*
* Usage: `node scripts/build-region-keys.mjs` (or `npm run build:region-keys`).
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
/**
* Letters present in the dataset that NFKD does NOT decompose, and their
* single/multi-letter ASCII equivalents, so their slugs aren't hyphen-soup.
* Kept deliberately tiny + deterministic (only the six that actually occur).
*/
const FOLD = {
'ə': 'a', // schwa
'ø': 'o', // o-stroke
'ð': 'd', // eth
'đ': 'dj', // d-stroke
'œ': 'oe', // oe ligature
'ł': 'l', // l-stroke
};
/**
* Readable slug from a region name. NFKD decomposes + we strip the combining
* marks, lowercase, fold the non-decomposing letters, collapse every other
* non-alphanumeric run to one hyphen, and trim leading/trailing hyphens.
*/
function slug(name) {
let s = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
s = s.toLowerCase();
s = [...s].map((ch) => FOLD[ch] ?? ch).join('');
s = s.replace(/[^a-z0-9]+/g, '-');
s = s.replace(/^-+|-+$/g, '');
return s;
}
/** FNV-1a 32-bit — a tiny, stable, pure string hash. */
function fnv1a32(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return h >>> 0;
}
/** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */
function code4(h) {
let out = '';
for (let i = 0; i < 4; i++) {
out += String.fromCharCode(97 + ((((h >>> (i * 8)) & 0xff) % 26)));
}
return out;
}
/** The 4-char code for an id, deterministically salted on within-group collision. */
function codeFor(id, used) {
let code = code4(fnv1a32(id));
let attempt = 0;
while (used.has(code)) {
attempt += 1;
code = code4(fnv1a32(`${id}#${attempt}`));
}
return code;
}
// --- load the region geo (plain parse; no geojson types) ---
const geoFile = path.join(root, 'public', 'regions.geo.json');
const geo = JSON.parse(readFileSync(geoFile, 'utf8'));
const features = Array.isArray(geo?.features) ? geo.features : [];
if (features.length !== 1796) {
throw new Error(`expected 1,796 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
}
// Group features by their (computed) name-slug, preserving file order, so the
// within-slug 4-char uniqueness resolution below is deterministic.
const groups = new Map(); // slug -> array of { id }
for (const f of features) {
const id = f.id == null ? '' : String(f.id);
const name = f.properties?.name;
if (!id || typeof name !== 'string') {
throw new Error(`region with id ${JSON.stringify(id)} has no usable name`);
}
const s = slug(name);
if (!groups.has(s)) groups.set(s, []);
groups.get(s).push({ id });
}
// Assign keys, resolving 4-char collisions within each slug group.
const manifest = {};
for (const [s, ids] of groups) {
const used = new Set();
for (const { id } of ids) {
manifest[id] = `${s}-${codeFor(id, used)}`;
used.add(manifest[id].slice(-4));
}
}
// --- hard assertions (the test re-checks these against the committed file) ---
const keys = Object.values(manifest);
if (new Set(keys).size !== keys.length) {
throw new Error(`region-key collision: ${keys.length} keys but only ${new Set(keys).size} unique`);
}
const KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[a-z]{4}$/;
for (const k of keys) {
if (!KEY_RE.test(k) || !k.includes('-')) {
throw new Error(`malformed region key: ${JSON.stringify(k)}`);
}
}
// --- write idempotently (stable order = insertion order = file order) ---
const out = JSON.stringify(manifest, null, 2) + '\n';
writeFileSync(path.join(root, 'public', 'region-keys.json'), out);
const western = Object.entries(manifest)
.filter(([, k]) => k.startsWith('western-'))
.sort((a, b) => a[0] < b[0] ? -1 : 1);
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,796 expected)`);
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
+61
View File
@@ -0,0 +1,61 @@
/**
* scripts/region-keys.test.ts — validation for the committed `public/region-keys.json`.
*
* The manifest maps each sub-region's **stable feature id** (from
* `public/regions.geo.json`) to a deterministic, human-readable, globally
* unique region key of the form `<name-slug>-<4char>` (built by
* `scripts/build-region-keys.mjs`). Region-level ownership (v0.19) keys both
* `Nation.territory` and the `RegionMap` on these keys, so the manifest is a
* load-bearing data artifact — this test pins its contract.
*
* Deliberately dependency-free: it reads both JSON files with plain
* `fs`/`JSON.parse` and never imports `@turf`/geojson types (importing one
* pulls `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see
* the v0.17 changelog note). Mirrors the style of `scripts/regions.test.ts`.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
function readJson(rel: string): Record<string, unknown> {
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
}
describe('region-keys.json', () => {
const manifest = readJson('public/region-keys.json') as Record<string, string>;
const geo = readJson('public/regions.geo.json') as {
features?: Array<{ id?: string | number }>;
};
const keys = Object.values(manifest);
const featureIds = (geo.features ?? []).map((f) => String(f.id));
it('has exactly 1,796 entries (one per region)', () => {
expect(Object.keys(manifest)).toHaveLength(1796);
});
it('has 1,796 unique keys (no collisions)', () => {
expect(new Set(keys).size).toBe(keys.length);
expect(new Set(keys).size).toBe(1796);
});
it('keys each end in -[a-z]{4} and contain at least one hyphen', () => {
const ending = /-[a-z]{4}$/;
for (const k of keys) {
expect(k, `key does not end in -[a-z]{4}: ${k}`).toMatch(ending);
expect(k, `key has no hyphen: ${k}`).toContain('-');
}
});
it('is a stable join: its keys are exactly the regions.geo.json feature ids', () => {
// The manifest is looked up by feature id, so its key set must line up
// 1:1 with the geo file (catches drift if the geo is regenerated without
// rebuilding the keys).
expect(featureIds).toHaveLength(1796);
const manifestIds = Object.keys(manifest);
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
});
});
+1 -6
View File
@@ -3,7 +3,6 @@ import { mkdirSync } from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import type { GameState } from '../shared/types';
import { migrateTerritoryToRegions } from './regions';
export interface Db {
save(state: GameState): void;
@@ -40,11 +39,7 @@ export function openDb(filePath: string): Db {
const row = byId.get(id) as { data: string } | undefined;
if (!row) return null;
try {
// v0.19: migrate any pre-v0.19 (whole-country ISO_A3) territory to
// sub-region ids on read. A no-op when the region geo is not loaded,
// and idempotent once a state has been migrated (the next save then
// persists the region-level territory).
return migrateTerritoryToRegions(JSON.parse(row.data) as GameState);
return JSON.parse(row.data) as GameState;
} catch {
return null;
}
+39 -87
View File
@@ -1,9 +1,8 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import type { GameState, Preset, RoundItem } from '../shared/types';
import { makeEvent, type TurnResult } from '../shared/protocol';
import type { LlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { _setRegionsForTests } from './regions';
import {
applyTurnResult,
buildSystemPrompt,
@@ -25,27 +24,6 @@ function fakeLlm(responses: string[]): LlmClient {
};
}
// Give the region module a small, deterministic index so `buildUserPrompt`'s
// `regions` reference table is populated in these fast unit tests (the real
// 3.4 MB geo stays out of this file). The ids match the synthetic territories
// below.
beforeAll(() => {
_setRegionsForTests(
{
'GB-01': { name: 'Greater London', parent: 'GBR' },
'GB-02': { name: 'Scotland', parent: 'GBR' },
'US-01': { name: 'New England', parent: 'USA' },
'US-02': { name: 'The Midwest', parent: 'USA' },
'PL-01': { name: 'Pomerania', parent: 'POL' },
'DE-01': { name: 'Rhineland', parent: 'DEU' },
},
{ GBR: ['GB-01', 'GB-02'], USA: ['US-01', 'US-02'], POL: ['PL-01'], DEU: ['DE-01'] },
);
});
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
// not whole-country ISO_A3 codes. `us` also holds `PL-01` so that a canned or
// stubbed gain of that region has a prior owner to move it from.
const preset: Preset = {
id: 'test',
title: 'Test',
@@ -59,7 +37,7 @@ const preset: Preset = {
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: ['GB-01', 'GB-02'],
territory: ['GBR'],
relations: { us: 'neutral', de: 'hostile' },
},
{
@@ -67,7 +45,7 @@ const preset: Preset = {
name: 'US',
type: 'major',
stats: { military: 60, economy: 90, stability: 80, population: 80 },
territory: ['US-01', 'US-02', 'PL-01'],
territory: ['USA'],
relations: { uk: 'neutral' },
},
{
@@ -75,7 +53,7 @@ const preset: Preset = {
name: 'DE',
type: 'major',
stats: { military: 95, economy: 70, stability: 75, population: 90 },
territory: ['DE-01'],
territory: ['DEU'],
relations: {},
},
],
@@ -110,7 +88,7 @@ const validResult = JSON.stringify({
{ nation: 'de', stat: 'economy', op: 'add', value: 10 },
],
relations: [{ a: 'uk', b: 'us', set: 'ally' }],
territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }],
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
});
describe('runTurn', () => {
@@ -131,9 +109,8 @@ describe('runTurn', () => {
// relations are symmetric
expect(state.nations.uk.relations.us).toBe('ally');
expect(state.nations.us.relations.uk).toBe('ally');
// the region moved to DE, out of its prior owner (us)
expect(state.nations.de.territory).toContain('PL-01');
expect(state.nations.us.territory).not.toContain('PL-01');
// territory moved to DE
expect(state.nations.de.territory).toContain('POL');
// one event appended, tagged with the turn that was resolved (1)
expect(state.events).toHaveLength(1);
expect(state.events[0].turn).toBe(1);
@@ -144,8 +121,7 @@ describe('runTurn', () => {
// and the original state object is NOT mutated
expect(original.turn).toBe(1);
expect(original.nations.uk.stats.military).toBe(80);
expect(original.nations.us.territory).toContain('PL-01');
expect(original.nations.de.territory).not.toContain('PL-01');
expect(original.nations.de.territory).not.toContain('POL');
});
it('clamps out-of-range stat values into 0-100', async () => {
@@ -215,7 +191,7 @@ describe('runTurn', () => {
});
describe('runTurn (batched round, deterministic stub)', () => {
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5), one deterministic region gain', async () => {
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5)', async () => {
const original = baseState();
original.pending = [{ kind: 'action', text: 'Fortify the coast and mobilize the home guard' }];
const { state, result } = await runTurn({
@@ -233,12 +209,6 @@ describe('runTurn (batched round, deterministic stub)', () => {
expect(state.events[0].nation).toBe('uk');
expect(state.events[0].outcome).toBe('success');
expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]);
// the stub's one deterministic per-turn territory move: the player annexes
// the lexicographically-first region of the first other nation (us holds
// PL-01, US-01, US-02 → PL-01 wins), moved out of us.
expect(result.territoryChanges).toEqual([{ nation: 'uk', gain: ['PL-01'], lose: [] }]);
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
expect(state.nations.us.territory).toEqual(['US-01', 'US-02']);
// committing a turn is a new revision too
expect(state.version).toBe(2);
// the original object is not mutated (a fresh queue was consumed)
@@ -246,7 +216,7 @@ describe('runTurn (batched round, deterministic stub)', () => {
expect(original.version).toBe(1);
});
it('an empty round: turn advances, one political "world moves on" event, no territory move', async () => {
it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
const { state } = await runTurn({
llm: makeStubLlmClient(),
preset,
@@ -260,13 +230,9 @@ describe('runTurn (batched round, deterministic stub)', () => {
expect(state.events[0].type).toBe('political');
expect(state.events[0].title).toBe('The world moves on');
expect(state.events[0].outcome).toBe('neutral');
// an empty round makes no move: every territory is untouched
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02']);
expect(state.nations.us.territory).toEqual(['US-01', 'US-02', 'PL-01']);
expect(state.nations.de.territory).toEqual(['DE-01']);
});
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance, one region gain', async () => {
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance', async () => {
const state = baseState();
state.pending = [
{ kind: 'action', text: 'Reinforce the Atlantic convoys' },
@@ -285,9 +251,6 @@ describe('runTurn (batched round, deterministic stub)', () => {
expect(next.events.filter((e) => e.type === 'military')).toHaveLength(2);
expect(next.events.filter((e) => e.type === 'diplomatic')).toHaveLength(1);
expect(next.nations.uk.stats.military).toBe(85); // one +5 per turn, not per item
// …and one (not three) territory move for the whole batch
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']);
});
});
@@ -301,7 +264,6 @@ describe('buildUserPrompt (batched round)', () => {
pending: RoundItem[];
playerActions: unknown[];
roundInstruction: string;
regions: Record<string, string>;
};
expect(parsed.pending).toEqual(items);
expect(parsed.playerActions).toEqual(['Fortify Dover', { to: 'us', message: 'Propose a pact' }]);
@@ -309,20 +271,6 @@ describe('buildUserPrompt (batched round)', () => {
expect(parsed.roundInstruction).toContain('Propose a pact');
});
it('emits a regions reference table ("id → name (parent)") covering every territory id in the state', () => {
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
regions: Record<string, string>;
state: { nations: Record<string, { territory: string[] }> };
};
// the table is keyed by the union of all nations' (sub-region) territory ids
const allIds = Object.values(parsed.state.nations).flatMap((n) => n.territory);
expect(Object.keys(parsed.regions).sort()).toEqual([...allIds].sort());
// each id maps to a human "name (parent)" label from the region index
expect(parsed.regions['GB-01']).toBe('Greater London (GBR)');
expect(parsed.regions['DE-01']).toBe('Rhineland (DEU)');
expect(parsed.regions['PL-01']).toBe('Pomerania (POL)');
});
it('frames an empty round as "the world advances on its own momentum"', () => {
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
pending: RoundItem[];
@@ -370,6 +318,17 @@ describe('buildUserPrompt (batched round)', () => {
it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => {
expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i);
});
it('includes a regions reference table only when one is provided', () => {
// with a reference table: the payload carries it (region key -> "name (parent)")
const withRef = JSON.parse(
buildUserPrompt(baseState(), [], { 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' }),
) as { regions?: Record<string, string> };
expect(withRef.regions).toEqual({ 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' });
// without one: the field is absent entirely (the legacy prompt shape is unchanged)
const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> };
expect(withoutRef.regions).toBeUndefined();
});
});
describe('queueRoundItem', () => {
@@ -514,46 +473,39 @@ describe('applyTurnResult', () => {
expect(state.version).toBe(1);
});
it('moves a gained region out of its prior owner (region-level transfer)', () => {
it('with a validKeys set: applies real region keys, ignores hallucinated ones', () => {
const state = baseState();
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }],
// 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz'
// is not a real key at all. Both are proposed as a gain by DE.
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
};
const next = applyTurnResult(state, result);
expect(next.nations.de.territory).toContain('PL-01');
expect(next.nations.us.territory).not.toContain('PL-01'); // spliced out of the prior owner
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02']);
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
// the valid key was gained (stolen from its owner); the bogus one was ignored
expect(next.nations.de.territory).toContain('wales-bbbb');
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
// the bogus key never entered any nation's territory
for (const n of Object.values(next.nations)) {
expect(n.territory).not.toContain('not-a-region-xyz');
}
});
it('adds a region nobody owned (no prior owner to splice from)', () => {
it('without a validKeys set: applies every listed key (legacy/unit path)', () => {
const state = baseState();
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [{ nation: 'uk', gain: ['FR-57'], lose: [] }],
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
};
const next = applyTurnResult(state, result);
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'FR-57']);
});
it('drops a lost region with no gainer (it becomes unowned / no-man\'s-land)', () => {
const state = baseState();
const result: TurnResult = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [{ nation: 'us', gain: [], lose: ['PL-01'] }],
};
const next = applyTurnResult(state, result);
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']);
expect(Object.values(next.nations).every((n) => !n.territory.includes('PL-01'))).toBe(true);
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('POL');
expect(next.nations.de.territory).toContain('wales-bbbb');
});
});
+26 -24
View File
@@ -24,7 +24,6 @@ import {
} from '../shared/protocol';
import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm';
import { ADVISOR_MARKER } from './stub';
import { regionNamesFor } from './regions';
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
@@ -54,12 +53,12 @@ export function buildSystemPrompt(preset: Preset): string {
' ],',
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
' "territoryChanges": [{"nation":"<nationId>","gain":["<regionId>"],"lose":["<regionId>"]}]',
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}]',
'}',
'',
'HARD CONSTRAINTS',
'- Use only nation ids and sub-region ids (e.g. `FR-57`) that exist in the provided state.',
'- A region id (e.g. `FR-57`) identifies a single sub-region — the `regions` table below lists each id with its name and country; a nation\'s border can change one region at a time.',
'- Use only nation ids and region keys that exist in the provided state.',
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.',
@@ -77,7 +76,11 @@ export function buildSystemPrompt(preset: Preset): string {
* model to simulate the combined consequences of the whole batch (or to let the
* world advance on its own when the batch is empty) and return one TurnResult.
*/
export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
export function buildUserPrompt(
state: GameState,
items: RoundItem[],
regionReference?: Record<string, string>,
): string {
const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId;
const playerActions = items.map((i) =>
@@ -90,7 +93,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
const payload = {
const payload: Record<string, unknown> = {
state: {
turn: state.turn,
date: state.date,
@@ -108,19 +111,11 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
outcome: e.outcome,
})),
},
// A name lookup for every region id referenced anywhere in the state, so the
// model can map an id it is considering (e.g. `FR-57`) to a place it
// recognizes. This is a top-level reference table keyed by sub-region id;
// each nation's own `territory` still carries raw ids (via nationForPrompt,
// unchanged) so the deterministic stub can parse them. When the region geo
// is not loaded the table is empty and the model falls back to the raw ids.
regions: regionNamesFor(
Object.values(state.nations).flatMap((n) => n.territory),
),
playerActions,
pending: items,
roundInstruction,
};
if (regionReference) payload.regions = regionReference;
return JSON.stringify(payload, null, 2);
}
@@ -294,7 +289,7 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
* Apply a TurnResult to a GameState immutably: advance the date/turn, append
* events, apply stat changes (clamped), symmetric relations, and territory moves.
*/
export function applyTurnResult(state: GameState, result: TurnResult): GameState {
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
const actingTurn = state.turn; // this action resolves the current turn
@@ -320,15 +315,18 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
for (const t of result.territoryChanges) {
const n = next.nations[t.nation];
if (!n) continue;
for (const code of t.gain) {
const valid = (k: string) => !validKeys || validKeys.has(k);
for (const key of t.gain) {
if (!valid(key)) continue; // ignore a key that is not a real region
for (const other of Object.values(next.nations)) {
const i = other.territory.indexOf(code);
const i = other.territory.indexOf(key);
if (i !== -1) other.territory.splice(i, 1);
}
if (!n.territory.includes(code)) n.territory.push(code);
if (!n.territory.includes(key)) n.territory.push(key);
}
for (const code of t.lose) {
const i = n.territory.indexOf(code);
for (const key of t.lose) {
if (!valid(key)) continue;
const i = n.territory.indexOf(key);
if (i !== -1) n.territory.splice(i, 1);
}
}
@@ -378,10 +376,14 @@ export async function runTurn(opts: {
preset: Preset;
state: GameState;
items: RoundItem[];
/** Region reference table (key -> "name (parent)") to include in the user prompt. */
regionReference?: Record<string, string>;
/** Set of valid region keys; territory changes with other keys are ignored. */
validRegionKeys?: ReadonlySet<string>;
}): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, items } = opts;
const { llm, preset, state, items, regionReference, validRegionKeys } = opts;
const system = buildSystemPrompt(preset);
const user = buildUserPrompt(state, items);
const user = buildUserPrompt(state, items, regionReference);
const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
@@ -409,7 +411,7 @@ export async function runTurn(opts: {
};
}
return { state: applyTurnResult(state, result), result };
return { state: applyTurnResult(state, result, validRegionKeys), result };
}
// --- Advisor (brainstorm) -----------------------------------------------------
+11 -15
View File
@@ -19,8 +19,8 @@ import type { GameState } from '../shared/types';
import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
import { expandTerritory, loadRegions } from './regions';
import { buildRegionMap } from './map';
import { allTerritoryKeys, expandCountriesToKeys, loadRegions, referenceForKeys, validKeys } from './regions';
import { createPresetStore } from './presets';
import { openDb, type Db } from './db';
@@ -42,10 +42,6 @@ function findRoot(): string {
}
const root = findRoot();
// Load the sub-region geo once at boot (a graceful no-op, with a warning, when
// the file is missing — the territory model then degrades to whole-country
// ISO_A3 codes). Must run before the first game creation/load.
loadRegions(root);
const presetsDir = path.join(root, 'presets');
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
@@ -54,6 +50,9 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
// the e2e config, so honor it as a fallback to keep all three in agreement.
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
const presetStore = createPresetStore(presetsDir);
// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file
// just means region expansion/reference degrade to the raw keys (server still boots).
loadRegions(root);
const db: Db = openDb(path.join(dataDir, 'history.db'));
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
const useStub = process.env.LLM_STUB === '1';
@@ -121,20 +120,15 @@ app.post('/api/games', (req: Request, res) => {
playerNationId: nation.id,
turn: 1,
date: preset.startDate,
// v0.19: territory is at sub-region granularity — expand each preset
// nation's ISO_A3 country codes into all their sub-region ids (a no-op that
// keeps the country codes when the region geo is not loaded).
nations: Object.fromEntries(
preset.nations.map((n) => {
const nation = deepClone(n);
nation.territory = expandTerritory(nation.territory);
return [n.id, nation];
}),
),
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
events: [],
pending: [],
version: 1, // the first saved revision; every later mutation bumps it
};
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
// of all its sub-regions, so ownership (and the choropleth) is at region
// granularity from the very first turn. Preset JSONs stay country-level.
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
db.save(state);
res.json({ game: state });
});
@@ -241,6 +235,8 @@ app.post('/api/games/:id/jump', async (req, res) => {
preset,
state: game,
items: game.pending,
regionReference: referenceForKeys(allTerritoryKeys(game)),
validRegionKeys: validKeys(),
});
db.save(state);
broadcast(id, 'turn', { state, result });
+47 -45
View File
@@ -3,8 +3,10 @@ import { buildRegionMap } from './map';
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types';
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
// not whole-country ISO_A3 codes.
// Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed
// by region key (one per owned sub-region), not by a parent-country ISO_A3. The
// keys below are deliberately fake (obviously not real manifest entries) so this
// test stays self-contained and format-agnostic.
const state: GameState = {
id: 'g',
presetId: 'p',
@@ -18,7 +20,7 @@ const state: GameState = {
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: ['GB-01', 'GB-02'],
territory: ['albania-aaaa', 'wales-bbbb'],
relations: {},
},
de: {
@@ -26,7 +28,7 @@ const state: GameState = {
name: 'DE',
type: 'major',
stats: { military: 90, economy: 70, stability: 70, population: 90 },
territory: ['DE-01'],
territory: ['brandenburg-cccc'],
relations: {},
},
},
@@ -35,29 +37,28 @@ const state: GameState = {
version: 1,
};
// The RegionMap built from the fixture: the authoritative sub-region id → owner
// source.
// The RegionMap built from the fixture: the authoritative region key → owner source.
const regions: RegionMap = buildRegionMap(state);
describe('buildRegionMap', () => {
it('maps each sub-region id to its owner + that nation\'s stable color', () => {
it('maps each owned region key to its owner + that nation\'s stable color', () => {
const m = buildRegionMap(state);
expect(m['GB-01']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m['GB-02']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m['DE-01']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
// a sub-region nobody owns is simply absent
expect(m['FR-57']).toBeUndefined();
expect(m['albania-aaaa']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m['wales-bbbb']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
expect(m['brandenburg-cccc']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
// a region nobody owns is simply absent
expect(m['normandie-dddd']).toBeUndefined();
});
});
describe('regionOwner', () => {
it('resolves an owned sub-region id to its owner record', () => {
expect(regionOwner(regions, 'GB-01')).toEqual({
it('resolves an owned region key to its owner record', () => {
expect(regionOwner(regions, 'albania-aaaa')).toEqual({
color: nationColor('uk'),
owner: 'UK',
ownerId: 'uk',
});
expect(regionOwner(regions, 'DE-01')).toEqual({
expect(regionOwner(regions, 'brandenburg-cccc')).toEqual({
color: nationColor('de'),
owner: 'DE',
ownerId: 'de',
@@ -65,11 +66,11 @@ describe('regionOwner', () => {
});
it('returns undefined for an unowned region (no RegionMap entry)', () => {
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
expect(regionOwner(regions, 'US-01')).toBeUndefined();
expect(regionOwner(regions, 'normandie-dddd')).toBeUndefined();
expect(regionOwner(regions, 'yorkshire-eeee')).toBeUndefined();
});
it('returns undefined for an empty or missing id', () => {
it('returns undefined for an empty or missing key', () => {
expect(regionOwner(regions, '')).toBeUndefined();
expect(regionOwner(regions, null)).toBeUndefined();
expect(regionOwner(regions, undefined)).toBeUndefined();
@@ -77,28 +78,29 @@ describe('regionOwner', () => {
});
/**
* #6 regression guard (sub-national map, v0.19 region-level ownership). The
* click/hover handlers resolve the owning nation from a rendered map feature.
* MapLibre repacks a GeoJSON source's top-level `id` into a *numeric index*
* when it stores the source, so a feature the map hands back at interaction
* time has a number for `id`. Since v0.19 ownership is at **sub-region**
* granularity: a feature's owner lives at its **own sub-region id** (e.g.
* `DE-01`), which the client stamps into `properties.regionId` (the RegionMap
* is keyed by that id). This pins the exact data shape and proves the
* resolution: `mapFeatureOwner` works via `properties.regionId`, while the
* repacked numeric `feature.id` path does not.
* #6 regression guard (sub-national map, region-keyed). The click/hover handlers
* resolve the owning nation from a rendered map feature. MapLibre repacks a
* GeoJSON source's top-level `id` into a *numeric index* when it stores the
* source, so a feature the map hands back at interaction time has a number for
* `id`. The choropleth is sub-national: a feature's own sub-region id is a code
* (`DEU-03`), and its owner now lives at its **region key** (`brandenburg-cccc`),
* which the client stamps into `properties.regionKey` from `region-keys.json`
* (the RegionMap is keyed by that key, never the sub-region id nor the repacked
* id). This pins the exact data shape and proves the resolution:
* `mapFeatureOwner` works via `properties.regionKey`, while the two wrong paths
* (the repacked numeric `feature.id`, and the sub-region `regionId`, which is
* not a RegionMap key) do not.
*/
describe('#6 — region owner must be resolved from properties.regionId, never the repacked feature.id', () => {
describe('#6 — sub-region owner must be resolved from properties.regionKey, not the sub-region id / repacked feature.id', () => {
// The shape MapLibre returns for a sub-region feature after setData(): `id`
// is a number (repacked), and the stable identifiers live only in properties
// — `regionId` (the sub-region id, the RegionMap key) and `parent` (the
// static parent country's ISO_A3).
// is a number (repacked), and the stable identifiers live only in properties —
// `regionId` (the sub-region code) and `regionKey` (the region key).
const repackedFeature = {
id: 0,
properties: { name: 'Rhineland', regionId: 'DE-01', parent: 'DEU' },
properties: { name: 'Brandenburg R03', regionId: 'DEU-03', regionKey: 'brandenburg-cccc' },
};
it('resolves the owner from properties.regionId (the fix)', () => {
it('resolves the owner from properties.regionKey (the fix)', () => {
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
color: nationColor('de'),
owner: 'DE',
@@ -106,30 +108,30 @@ describe('#6 — region owner must be resolved from properties.regionId, never t
});
});
it('the repacked numeric feature.id does NOT resolve — the original bug', () => {
it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
// 'DEU-03' is a sub-region id; the RegionMap is keyed by region key.
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
});
it('the repacked numeric feature.id does NOT resolve — the bug', () => {
// String(0) === '0' is not a valid key, so the pre-fix lookup failed and
// reported "No owner" even for owned regions.
expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined();
});
it('a sub-region id nobody owns does NOT resolve', () => {
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
});
});
describe('mapFeatureOwner', () => {
it('returns undefined for a missing feature, missing regionId, or non-string regionId', () => {
it('returns undefined for a missing feature, missing regionKey, or non-string regionKey', () => {
expect(mapFeatureOwner(regions, null)).toBeUndefined();
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
expect(mapFeatureOwner(regions, {})).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: 42 } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: '' } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionKey: 42 } })).toBeUndefined();
});
it('returns undefined for a sub-region nobody owns', () => {
expect(mapFeatureOwner(regions, { properties: { regionId: 'FR-57' } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionId: 'US-01' } })).toBeUndefined();
it('returns undefined for a region key nobody owns', () => {
expect(mapFeatureOwner(regions, { properties: { regionKey: 'normandie-dddd' } })).toBeUndefined();
expect(mapFeatureOwner(regions, { properties: { regionKey: 'yorkshire-eeee' } })).toBeUndefined();
});
});
+7 -9
View File
@@ -1,10 +1,8 @@
// Build the choropleth payload from a GameState: every **sub-region id** in a
// nation's territory maps to its owning nation plus that nation's stable color
// (see shared.nationColor). The client merges this onto public/regions.geo.json
// and calls source.setData(). The map is keyed by whatever ids sit in
// `nation.territory` — sub-region ids (e.g. `FR-57`) as of v0.19 — so it needs
// no change to move from country to sub-region granularity; a sub-region in no
// nation's territory is simply absent (unowned, rendered grey by the map).
// Build the choropleth payload from a GameState: every region key (see
// public/region-keys.json) maps to its owning nation plus that nation's stable
// color (see shared.nationColor). The client merges this onto the sub-national
// regions.geo.json (resolving each feature by its own region key) and calls
// source.setData(). Format-agnostic: it maps whatever keys sit in territory.
import { nationColor, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types';
@@ -12,8 +10,8 @@ export function buildRegionMap(state: GameState): RegionMap {
const map: RegionMap = {};
for (const [id, nation] of Object.entries(state.nations)) {
const color = nationColor(id);
for (const regionId of nation.territory) {
map[regionId] = { color, owner: nation.name, ownerId: id };
for (const key of nation.territory) {
map[key] = { color, owner: nation.name, ownerId: id };
}
}
return map;
+90 -163
View File
@@ -1,176 +1,103 @@
/**
* server/regions.test.ts — the sub-region data module (v0.19).
*
* The first suite loads the REAL `public/regions.geo.json` (the same file the
* production server loads at boot), so expansion/migration runs against the
* actual 1,796-region dataset. The second suite exercises the graceful
* "geo missing" path (module not loaded) via the `_resetRegionsForTests` seam,
* and pins the `_setRegionsForTests` fixture seam used by the other unit tests.
* Module state is per vitest file, so these suites never collide with other
* test files.
*/
import path from 'node:path';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { GameState } from '../shared/types';
import {
_resetRegionsForTests,
_setRegionsForTests,
expandTerritory,
allTerritoryKeys,
expandCountriesToKeys,
hasRegionData,
loadRegions,
migrateTerritoryToRegions,
regionLabel,
regionNamesFor,
referenceForKeys,
regionName,
resetRegions,
validKeys,
} from './regions';
import type { GameState } from '../shared/types';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
// The repo root: this file lives in server/, so '..' is the directory holding
// public/region-keys.json and public/regions.geo.json. cwd-independent (mirrors
// how server/index.ts locates its root via import.meta.url).
const root = fileURLToPath(new URL('..', import.meta.url));
function makeState(territory: string[]): GameState {
return {
id: 'g',
presetId: 'p',
presetTitle: 'P',
playerNationId: 'uk',
turn: 1,
date: '1939-09-01',
nations: {
uk: {
id: 'uk',
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory,
relations: {},
describe('regions (region-key loader, v0.19 #16 P1)', () => {
beforeEach(() => {
expect(loadRegions(root)).toBe(true);
});
it('loads the full manifest: 1,796 unique valid region keys', () => {
expect(hasRegionData()).toBe(true);
expect(validKeys().size).toBe(1796);
});
it('expands an ISO_A3 country to the region keys of all its sub-regions', () => {
const fra = expandCountriesToKeys(['FRA']);
// FRA has 42 sub-regions in the committed dataset.
expect(fra).toHaveLength(42);
// every expanded key is a real region key
for (const k of fra) expect(validKeys().has(k)).toBe(true);
// and the reference table labels each one with its parent country
const ref = referenceForKeys(fra);
expect(Object.keys(ref)).toHaveLength(42);
for (const [k, label] of Object.entries(ref)) {
expect(label.endsWith('(FRA)')).toBe(true);
expect(regionName(k)).toBeTruthy();
}
// sample known FRA keys, and their exact reference strings
expect(fra).toContain('ain-lqtt');
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
expect(ref['aisne-gstu']).toBe('Aisne (FRA)');
});
it('referenceForKeys labels only known keys and omits unknown ones', () => {
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
expect(ref['not-a-real-key-xyz']).toBeUndefined();
});
it('allTerritoryKeys unions every nation\'s territory keys (no overlap)', () => {
const state: GameState = {
id: 'g',
presetId: 'p',
presetTitle: 'P',
playerNationId: 'uk',
turn: 1,
date: '1939-09-01',
nations: {
uk: {
id: 'uk',
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: expandCountriesToKeys(['GBR']),
relations: {},
},
fr: {
id: 'fr',
name: 'FR',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: expandCountriesToKeys(['FRA']),
relations: {},
},
},
},
events: [],
pending: [],
version: 1,
};
}
describe('server/regions (real geo)', () => {
beforeAll(() => {
_resetRegionsForTests();
loadRegions(root);
events: [],
pending: [],
version: 1,
};
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58);
});
it('loads public/regions.geo.json (hasRegionData becomes true)', () => {
expect(hasRegionData()).toBe(true);
describe('graceful degradation with no region data', () => {
beforeEach(() => resetRegions());
it('expands to the raw input, omits references, and reports no data', () => {
expect(hasRegionData()).toBe(false);
// no manifest -> the country list passes through unchanged (old model)
expect(expandCountriesToKeys(['FRA', 'GBR'])).toEqual(['FRA', 'GBR']);
expect(referenceForKeys(['FRA'])).toEqual({});
expect(validKeys().size).toBe(0);
expect(regionName('FRA')).toBeUndefined();
});
});
it('loadRegions is idempotent: a second call keeps the loaded data', () => {
loadRegions(root);
expect(hasRegionData()).toBe(true);
});
it('expandTerritory expands an ISO_A3 into its sub-region ids (unique, all with that parent)', () => {
const ids = expandTerritory(['FRA']);
expect(ids.length).toBeGreaterThan(1); // France is subdivided, not one region
expect(new Set(ids).size).toBe(ids.length); // deduped
// every expanded id resolves back to the same parent country
const names = regionNamesFor(ids);
for (const id of ids) expect(names[id]).toMatch(/\(FRA\)$/);
});
it('expandTerritory is order-preserving and dedupes repeated country codes', () => {
expect(expandTerritory(['FRA', 'FRA'])).toEqual(expandTerritory(['FRA']));
// two countries: the union, in first-seen order (the ids are disjoint)
expect(expandTerritory(['FRA', 'DEU'])).toEqual([
...expandTerritory(['FRA']),
...expandTerritory(['DEU']),
]);
});
it('expandTerritory of an empty or unknown list yields no regions', () => {
expect(expandTerritory([])).toEqual([]);
expect(expandTerritory(['XXX'])).toEqual([]); // no country with that code
});
it('regionNamesFor maps every known id to "name (parent)"', () => {
const ids = expandTerritory(['DEU']);
const names = regionNamesFor(ids);
expect(Object.keys(names).sort()).toEqual([...ids].sort());
for (const id of ids) expect(names[id]).toMatch(/\(DEU\)$/);
});
it('regionNamesFor skips ids that are not in the loaded index', () => {
expect(regionNamesFor(['NO-SUCH-01'])).toEqual({});
});
it('regionLabel is "id — name (parent)" for known ids and the bare id for unknown', () => {
const [id] = expandTerritory(['FRA']);
expect(regionLabel(id)).toMatch(new RegExp(`^${id} — .+ \\(FRA\\)$`));
expect(regionLabel('NO-SUCH-01')).toBe('NO-SUCH-01');
});
it('migrateTerritoryToRegions rewrites ISO_A3 entries to region ids, keeps existing region ids, and is idempotent', () => {
const [deId] = expandTerritory(['DEU']); // a real region id of another country
const state = makeState(['FRA', deId]); // mixed: legacy ISO_A3 + already migrated
const originalArray = state.nations.uk.territory;
// it mutates and returns the (fresh) state it is given — callers pass a
// just-parsed object (db.load), so the in-place mutation is safe
const out = migrateTerritoryToRegions(state);
expect(out).toBe(state);
// the original array object is never mutated in place — a new one is
// installed on the nation, and the original stays as it was
expect(out.nations.uk.territory).not.toBe(originalArray);
expect(originalArray).toEqual(['FRA', deId]);
// the ISO_A3 entry is gone, replaced by its sub-region ids…
expect(out.nations.uk.territory).not.toContain('FRA');
for (const id of expandTerritory(['FRA'])) expect(out.nations.uk.territory).toContain(id);
// …and the existing region id survives, exactly once
expect(out.nations.uk.territory.filter((t) => t === deId)).toEqual([deId]);
// idempotent: a second migration changes nothing
expect(migrateTerritoryToRegions(out).nations.uk.territory).toEqual(out.nations.uk.territory);
});
});
describe('server/regions (fallback: geo not loaded)', () => {
beforeAll(() => {
_resetRegionsForTests();
});
afterAll(() => {
// restore the real geo for anything in this file that runs later
_resetRegionsForTests();
loadRegions(root);
});
it('hasRegionData is false after a reset', () => {
expect(hasRegionData()).toBe(false);
});
it('expandTerritory returns its input unchanged (whole-country fallback)', () => {
expect(expandTerritory(['FRA'])).toEqual(['FRA']);
expect(expandTerritory([])).toEqual([]);
});
it('regionNamesFor is empty and regionLabel falls back to the bare id', () => {
expect(regionNamesFor(['FRA'])).toEqual({});
expect(regionLabel('FRA')).toBe('FRA');
});
it('migrateTerritoryToRegions is a no-op (returns the state untouched)', () => {
const state = makeState(['FRA']);
const out = migrateTerritoryToRegions(state);
expect(out).toBe(state);
expect(out.nations.uk.territory).toEqual(['FRA']);
});
it('_setRegionsForTests loads a fixture and drives the pure helpers', () => {
_setRegionsForTests(
{
'GB-01': { name: 'Greater London', parent: 'GBR' },
'GB-02': { name: 'Scotland', parent: 'GBR' },
},
{ GBR: ['GB-01', 'GB-02'] },
);
expect(hasRegionData()).toBe(true);
expect(expandTerritory(['GBR'])).toEqual(['GB-01', 'GB-02']);
expect(regionNamesFor(['GB-01'])).toEqual({ 'GB-01': 'Greater London (GBR)' });
const state = makeState(['GBR']);
expect(migrateTerritoryToRegions(state).nations.uk.territory).toEqual(['GB-01', 'GB-02']);
});
afterAll(() => resetRegions());
});
+93 -140
View File
@@ -1,102 +1,104 @@
// 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):
// 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:
//
// - `regionIndex` : region id → { name, parent } (parent = ISO_A3 country)
// - `regionsByCountry`: ISO_A3 country → array of its region ids
// - 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)
//
// 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.
// 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';
/** A region's stable identity: display name + the parent country's ISO_A3. */
export interface RegionInfo {
/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */
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;
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;
/**
* (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.
* 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): 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;
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 geo = JSON.parse(readFileSync(file, 'utf8')) as {
features?: Array<{
id?: string | number;
properties?: { name?: string; parent?: string };
}>;
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 index: Record<string, RegionInfo> = {};
const byCountry: Record<string, 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 parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
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 : '';
if (!id || !parent) continue;
index[id] = { name, parent };
(byCountry[parent] ??= []).push(id);
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
keyToInfo[key] = { name, parent };
validKeys.add(key);
if (parent) (keysByCountry[parent] ??= []).push(key);
}
regionIndex = index;
regionsByCountry = byCountry;
loaded = Object.keys(index).length > 0;
} catch (e) {
console.warn('[history] failed to load regions.geo.json (' + file + '):', e);
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 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).
* 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 expandTerritory(countryCodes: string[]): string[] {
if (!loaded) return countryCodes;
const seen = new Set<string>();
export function expandCountriesToKeys(countries: string[]): string[] {
if (!data) return countries;
const out: string[] = [];
for (const code of countryCodes) {
for (const id of regionsByCountry[code] ?? []) {
if (!seen.has(id)) {
seen.add(id);
out.push(id);
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);
}
}
}
@@ -104,84 +106,35 @@ export function expandTerritory(countryCodes: string[]): string[] {
}
/**
* 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.
* 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 regionNamesFor(ids: Iterable<string>): Record<string, string> {
export function referenceForKeys(keys: 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})`;
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;
}
/**
* 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;
/** 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;
}
/** 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;
/** 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)) {
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;
for (const key of n.territory) out.add(key);
}
return state;
return out;
}
// --- 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;
/** 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>();
}
+3 -12
View File
@@ -13,8 +13,7 @@ export interface StubPayload {
turn: number;
date: string;
playerNationId: string;
/** As of v0.19 territory is a list of sub-region ids (not ISO_A3 codes). */
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
nations: Record<string, { stats: Record<string, number> }>;
};
/** The raw pending round the engine emitted (one entry per queued item). */
pending?: RoundItem[];
@@ -31,12 +30,7 @@ function truncate(s: string, n: number): string {
* single political "The world moves on" event when the round is empty — so a
* caller can predict the exact next state for assertions. It always sets the
* player's military to (current + 5) and allies the player with the first other
* nation (by key order), advancing the date by 2 days. As of v0.19, when the
* round is non-empty it also makes exactly **one** territory move: the player
* annexes the lexicographically-first sub-region id of that first other
* nation's territory (an empty round makes no move, and a player with nothing
* to annex from that nation also makes no move) — so unit tests and e2e can
* assert the exact region that changes hands.
* nation, advancing the date by 2 days.
*/
export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state;
@@ -47,9 +41,6 @@ export function deterministicTurn(p: StubPayload): TurnResult {
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
: 'us';
const items = p.pending ?? [];
// The one deterministic per-turn territory move (see the doc above).
const otherTerritory = [...(state?.nations?.[otherId]?.territory ?? [])].sort();
const gain = items.length > 0 && otherTerritory.length > 0 ? [otherTerritory[0]] : [];
const events: TurnEventSpec[] =
items.length === 0
@@ -85,7 +76,7 @@ export function deterministicTurn(p: StubPayload): TurnResult {
events,
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
territoryChanges: gain.length > 0 ? [{ nation: pid, gain, lose: [] }] : [],
territoryChanges: [],
};
}
+26 -30
View File
@@ -23,13 +23,7 @@ export interface RelationChange {
set: RelationStatus;
}
/**
* A nation gains/loses **sub-region ids** this turn (e.g. `FR-57`), not
* whole-country ISO_A3 codes. Each id identifies a single sub-region, so a
* border can shift one region at a time; a gained region is moved out of
* whichever nation currently owns it, and a lost region with no matching
* gainer simply becomes unowned (no-man's-land).
*/
/** A nation gains/loses region keys (see `public/region-keys.json`) this turn. */
export interface TerritoryChange {
nation: string;
gain: string[];
@@ -54,31 +48,32 @@ export interface TurnResult {
territoryChanges: TerritoryChange[];
}
/** Per-region map payload: **sub-region id** -> owning nation + its stable color. */
/** Per-region map payload: region key -> owning nation + its stable color. */
export interface RegionOwner {
color: string;
owner: string;
ownerId: string;
}
/**
* The authoritative region→owner map, **keyed by sub-region id** (e.g. `FR-57`).
* Every sub-region any nation owns appears here; a sub-region in no nation's
* territory is absent (unowned / no-man's-land) and rendered grey by the map.
* The authoritative ownership map: a **region key** (see `public/region-keys.json`)
* -> the owning nation + its stable color. Owned at sub-region granularity (v0.19,
* #16 P1); a key absent from this map is unowned (rendered grey).
*/
export type RegionMap = Record<string, RegionOwner>;
/**
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
* The RegionMap is keyed by the **sub-region's own id** (e.g. `FR-57` — never a
* bare ISO_A3 like `FRA`, and never the repacked numeric `feature.id`), so `key`
* is a sub-region id such as `FR-57`. Returns `undefined` for an empty/missing
* key or an unowned region (one in no nation's territory).
* The RegionMap is keyed by **region key** (see `public/region-keys.json`) — one
* per owned sub-region (v0.19, #16 P1) — so `key` is a region key such as
* `moselle-rygy`, never a raw feature id such as `FR-57` and never a
* parent-country ISO_A3. Returns `undefined` for an empty/missing key or an
* unowned region.
*
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner}
* below, which reads the feature's `properties.regionId` — never the repacked
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug
* #6).
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below,
* which reads the feature's stamped `properties.regionKey` — never the repacked
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug #6)
* and never the sub-region id.
*/
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
if (!key) return undefined;
@@ -87,22 +82,23 @@ export function regionOwner(regions: RegionMap, key: string | null | undefined):
/**
* Resolve the owning-nation record for a **rendered map feature** from the
* authoritative RegionMap. Ownership is now at **sub-region** granularity, so a
* feature's owner is found via its **own sub-region id** — the client stamps
* the feature's id into `properties.regionId` (see WorldMap `applyRegions`), and
* we look that up with `regionOwner`. This is the single shared source of truth
* for that resolution, so the "use `properties.regionId`, never the repacked
* top-level `feature.id` (a MapLibre numeric index, the "no owner" bug #6)" rule
* lives in one tested place rather than in the React component.
* authoritative RegionMap. The choropleth renders sub-national regions, and the
* RegionMap is now keyed by **region key** (one per sub-region, v0.19 #16 P1),
* so a feature's owner is found via its `properties.regionKey` — the region key
* the client stamps from `public/region-keys.json` by the feature's stable id
* (see WorldMap) — and then `regionOwner`. This is the single shared source of
* truth for that resolution, so the "use `regionKey`, never the sub-region `id`
* / repacked `feature.id`" rule lives in one tested place rather than in the
* React component.
*
* Returns `undefined` for a missing feature/regionId or an unowned region.
* Returns `undefined` for a missing feature/regionKey or an unowned region.
*/
export function mapFeatureOwner(
regions: RegionMap,
feature: { properties?: { regionId?: unknown } } | null | undefined,
feature: { properties?: { regionKey?: unknown } } | null | undefined,
): RegionOwner | undefined {
const regionId = feature?.properties?.regionId;
return regionOwner(regions, typeof regionId === 'string' ? regionId : '');
const regionKey = feature?.properties?.regionKey;
return regionOwner(regions, typeof regionKey === 'string' ? regionKey : '');
}
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
+4 -9
View File
@@ -26,15 +26,10 @@ export const RELATION_STATUSES: RelationStatus[] = [
];
/**
* A single nation in the world. `territory` holds **sub-region ids** (e.g.
* `FR-57`) — one entry per individually-ownable sub-region, at sub-region
* granularity rather than whole-country ISO_A3. The map colors each sub-region
* with this nation's stable color (see protocol.nationColor). At scenario start
* a nation owns **all** sub-regions of the countries it holds (the server
* expands each ISO_A3 to its sub-regions; see server/regions.expandTerritory),
* so no region starts unowned. A sub-region that is in **no** nation's list is
* unowned (no-man's-land) — the map renders it grey and it can be gained or
* lost region-by-region by the LLM.
* A single nation in the world. `territory` holds **region keys** (see
* `public/region-keys.json`) — one per owned sub-region, so the choropleth can
* color individual sub-regions, not whole countries (v0.19, #16 P1). The map
* resolves each sub-region's owner by its region key (see protocol.mapFeatureOwner).
*/
export interface Nation {
id: string;
+4 -5
View File
@@ -591,12 +591,11 @@ function GameScreen({
const [viewingId, setViewingId] = useState(game.playerNationId);
// When the dossier target was set by a map click, remember which sub-region
// was clicked, for a small label in the dossier. The label shows only the
// region's name — the static parent-country code (which never updates on a
// transfer) is no longer displayed, though the info object still carries it.
// Cleared when the user picks a nation via the manual Inspect select.
const [selectedRegion, setSelectedRegion] = useState<{ parent?: string; name?: string } | null>(null);
// region's name. Cleared when the user picks a nation via the manual Inspect
// select.
const [selectedRegion, setSelectedRegion] = useState<{ name?: string } | null>(null);
const focusNation = useCallback(
(id: string, info?: { parent?: string; name?: string }) => {
(id: string, info?: { name?: string }) => {
setViewingId(id);
setSelectedRegion(info ?? null);
},
+33 -9
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { Badge } from './ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import StatBar from './StatBar';
import { nationColor } from '@shared/protocol';
import { RELATION_COLORS } from '@/lib/colors';
import { keyToName } from '@/lib/regions';
import type { Nation, RelationStatus } from '@shared/types';
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
@@ -38,6 +40,23 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
const regions = nation.territory;
// Human region names for the territory chips (region key -> name), loaded once
// (cached in @/lib/regions). A key with no known name falls back to the raw key.
const [keyNames, setKeyNames] = useState<Record<string, string>>({});
useEffect(() => {
let cancelled = false;
keyToName()
.then((m) => {
if (!cancelled) setKeyNames(m);
})
.catch(() => {
/* names are cosmetic — a failed fetch just leaves the raw keys */
});
return () => {
cancelled = true;
};
}, []);
const identity = (
<div className="flex items-center gap-3">
<span
@@ -95,15 +114,20 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals
</h3>
{regions.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{regions.map((code) => (
<span
key={code}
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
>
{code}
</span>
))}
{regions.map((key) => {
const label = keyNames[key] ?? key;
return (
<span
key={key}
className="rounded border px-1.5 py-0.5 text-xs"
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
title={key}
data-region-key={key}
>
{label}
</span>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No territory.</p>
+126 -102
View File
@@ -11,13 +11,12 @@ maplibregl.setWorkerUrl(workerUrl);
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
import type { Nation } from '@shared/types';
import { cn } from '@/lib/utils';
import { idToKey } from '@/lib/regions';
// Minimal GeoJSON types (avoids a hard @types/geojson dependency). The choropleth
// source is the sub-national `regions.geo.json`: each feature is a sub-region with
// a stable top-level `id` (ISO 3166-2 style, e.g. `FR-57`, `US-AK`) and
// a top-level `id` of the form `<ISO_A3>-<nn>` (e.g. `FRA-03`) and
// `properties = { name, parent }` where `parent` is the parent country's ISO_A3.
// As of v0.19 the top-level `id` is also the RegionMap key: ownership (and the
// LLM) live at sub-region granularity, so a nation owns individual sub-regions.
interface GeoFeature {
type: 'Feature';
id?: string | number;
@@ -103,42 +102,44 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
/**
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
* Since v0.19 ownership and the LLM live at **sub-region** granularity: a nation
* owns individual sub-region ids, so each sub-region carries the color of the
* nation that owns that exact sub-region (a country can now be split between
* nations, and a sub-region in no nation's territory renders grey). Each
* sub-region therefore carries:
* Ownership is at **region-key** granularity (v0.19, #16 P1): each sub-region has
* its own region key (from `public/region-keys.json`, looked up by the feature's
* stable id) and the RegionMap is keyed by that region key, so a single
* sub-region can change owner while its neighbours keep theirs. Each feature
* therefore carries:
*
* - `regionId` – its own sub-region id (e.g. `FR-57`), kept in a property that
* - `regionId` – its own sub-region id (e.g. `FRA-03`), kept in a property that
* survives into the features MapLibre hands back on click/hover
* (the top-level `id` is repacked into a numeric index, #6) —
* and the **RegionMap key** the owner is resolved through;
* - `parent` – the parent country's ISO_A3 (e.g. `FRA`); a static geographic
* fact shown in the dossier label, **not** used for ownership;
* - `fill` – the owning nation's stable color (from `RegionMap[regionId]`);
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned sub-region);
* (the top-level `id` is repacked into a numeric index, #6);
* - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key;
* - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`);
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned region);
* - `stroke` – a per-border alpha (owned brighter than unowned);
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
* player-highlight border).
*
* Crucially, the owner lookup keys the RegionMap by the feature's **own
* sub-region id** (as `properties.regionId` at interaction time, via
* `mapFeatureOwner` in @shared/protocol) — **never** by the repacked top-level
* `id` (a MapLibre numeric index, #6) and **never** by the parent country code.
* Crucially, the owner lookup keys the RegionMap by the stamped
* `properties.regionKey`, **never** by the sub-region id or the repacked
* top-level `id` — the RegionMap has no `FRA-03` entries, only region keys.
*/
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
function applyRegions(
geo: FeatureCollection,
regions: RegionMap,
playerNationId: string,
idKey: Record<string, string>,
): FeatureCollection {
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
for (const f of out.features) {
f.properties = f.properties ?? {};
// The sub-region's own id (e.g. `FR-57`) — stamped into a property that
// The sub-region's own id (e.g. `FRA-03`) — stamped into a property that
// survives into the rendered features (the top-level `id` gets repacked).
const regionId = f.id == null ? '' : String(f.id);
f.properties.regionId = regionId;
// The parent country's ISO_A3 (e.g. `FRA`) — a static geographic fact used
// for the dossier label only; ownership is resolved by `regionId`.
const parent = typeof f.properties.parent === 'string' ? f.properties.parent : '';
f.properties.parent = parent;
const r = regionId ? regions[regionId] : undefined;
const subId = f.id == null ? '' : String(f.id);
f.properties.regionId = subId;
// Resolve this feature's **region key** from the manifest (id -> key) and
// key the RegionMap by it — never the sub-region id or the repacked id.
const key = idKey[subId] ?? '';
f.properties.regionKey = key;
const r = key ? regions[key] : undefined;
if (r) {
f.properties.fill = r.color;
f.properties.owner = r.owner;
@@ -157,15 +158,22 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
}
/**
* Build a FeatureCollection of just the sub-regions whose **own sub-region id**
* is in `regionIds` (cloned). Used by the territory-change flash: since v0.19
* a turn changes individual sub-regions (the RegionMap is keyed by sub-region
* id), so each changed id lights up exactly its feature — no expansion to a
* whole country needed.
* Build a FeatureCollection of just the sub-regions whose **region key** is in
* `keys` (cloned). Used by the territory-change flash: a turn can change
* individual sub-regions (the RegionMap is region-keyed, v0.19 #16 P1), so each
* changed region key lights up exactly the sub-region that owns it.
*/
function featuresByRegionIds(geo: FeatureCollection, regionIds: Iterable<string>): FeatureCollection {
const set = new Set(regionIds);
const features = geo.features.filter((f) => f.id != null && set.has(String(f.id)));
function featuresByRegionKeys(
geo: FeatureCollection,
keys: Iterable<string>,
idKey: Record<string, string>,
): FeatureCollection {
const set = new Set(keys);
const features = geo.features.filter((f) => {
const id = f.id == null ? '' : String(f.id);
const key = idKey[id] ?? '';
return key !== '' && set.has(key);
});
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
}
@@ -179,11 +187,11 @@ function statRow(label: string, value?: number): string {
/**
* Compact hover tooltip: the sub-region's name, its **current owner**
* (color-chipped), and 1–2 key stats. When the region has no owning nation, a
* minimal "No owner" card is shown instead. The static parent-country code
* (`properties.parent`) is deliberately NOT printed here — it is a geographic
* fact that never updates when ownership is transferred, so only the live
* owner chip (resolved from the RegionMap by sub-region id) is shown.
* (color-chipped), and 1–2 key stats. When the region's country has no owning
* nation, a minimal "No owner" card is shown instead. The static
* parent-country code (`properties.parent`) is deliberately NOT printed here —
* it never updates when ownership is transferred, so only the live owner chip
* (resolved from the RegionMap) is shown.
*/
function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string {
if (!region || !nation) {
@@ -207,39 +215,36 @@ interface Props {
className?: string;
/**
* Called with a region's owning-nation id when a sub-region is clicked, so the
* dossier switches to that nation. Since v0.19 the owner is the nation that
* owns that very sub-region (resolved from the RegionMap by sub-region id).
* The optional `info` carries the clicked sub-region's own name and its parent
* country's ISO_A3, for a small "which region" label in the dossier. Not
* dossier switches to that nation. The optional `info` carries the clicked
* sub-region's name, for a small "which region" label in the dossier. Not
* called for unowned regions (the dossier keeps its current target). See the
* component doc for the full behavior.
*/
onFocusNation?: (id: string, info?: { parent?: string; name?: string }) => void;
onFocusNation?: (id: string, info?: { name?: string }) => void;
}
/**
* World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
* the finer `regions.geo.json` dataset: each country is subdivided into
* ~100–200 km sub-regions, drawn as a finer choropleth with thin internal border
* lines. Since v0.19 ownership and the LLM live at **sub-region** granularity:
* a nation owns individual sub-region ids, so each feature is filled with the
* color of the nation owning that exact sub-region (a country can be split
* between nations; unowned sub-regions render grey). Rendered as a fixed,
* full-viewport background layer (z-0) behind the floating HUD windows: a
* dark-navy basemap with subtle terrain, per-region stable fills (the
* choropleth is the star), thin sub-region borders, a brighter/thicker amber
* border for the player's regions, a hover tooltip (sub-region name + current
* owner + key stats), a **click that switches the dossier to the sub-region's
* owning nation** (no popup — see #7), a transient flash when a region's owner
* changes (lighting up exactly the changed features), and a per-scenario
* initial camera.
* lines. Ownership is at **region-key** granularity (v0.19, #16 P1): each
* sub-region has its own region key (from `public/region-keys.json`, looked up
* by the feature's stable id) and the RegionMap is keyed by that key, so a
* single sub-region can change owner independently of its neighbours. Rendered
* as a fixed, full-viewport background layer (z-0) behind the floating HUD
* windows: a dark-navy basemap with subtle terrain, per-nation stable fills,
* thin sub-region borders, a brighter/thicker amber border for the player's
* regions, a hover tooltip (sub-region name + current owner + key stats), a
* **click that switches the dossier to the sub-region's owning nation** (no
* popup — see #7), a transient flash when a sub-region's owner changes, and a
* per-scenario initial camera.
*
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
* RegionMap by the feature's **own sub-region id** (`properties.regionId`, via
* RegionMap by its stamped **region key** (`properties.regionKey`, via
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
* `id` (MapLibre repacks it into a numeric index at interaction time, #6).
* Unowned sub-regions (no RegionMap entry for the id) simply do not change the
* dossier.
* `id` (MapLibre repacks it into a numeric index at interaction time, #6) and
* never from the raw sub-region id (not a RegionMap key). Unowned sub-regions
* (no RegionMap entry for their key) simply do not change the dossier.
*
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
* — no floating control and no external API.
@@ -255,6 +260,9 @@ export default function WorldMap({
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const baseGeoRef = useRef<FeatureCollection | null>(null);
// The region-key manifest (feature id -> region key), loaded once; used to
// stamp each feature's region key and to expand the flash to its sub-region.
const idKeyRef = useRef<Record<string, string> | null>(null);
// Latest props, so the mount effect's (re)establish closure always sees them.
const regionsRef = useRef(regions);
const nationsRef = useRef(nations);
@@ -310,17 +318,28 @@ export default function WorldMap({
return g;
});
// The region-key manifest (feature id -> region key), fetched once and
// cached in a ref so both the paint path and the flash can resolve keys.
const loadIdKey = (): Promise<Record<string, string>> =>
idKeyRef.current
? Promise.resolve(idKeyRef.current)
: idToKey().then((k) => {
idKeyRef.current = k;
return k;
});
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
// Transient flash: pulse a bright outline over the changed regions, then fade
// to zero over a few seconds and self-clean. Idempotent per call. `codes`
// are sub-region ids (the RegionMap keys that changed owner since the last
// update); each lights up exactly its feature.
// to zero over a few seconds and self-clean. Idempotent per call. `codes` are
// region keys (a turn can change individual sub-regions, v0.19 #16 P1); each
// lights up exactly the sub-region that owns that key.
function animateFlash(codes: string[]) {
const geo = baseGeoRef.current;
const idKey = idKeyRef.current;
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
if (!geo || !src || cancelled) return;
src.setData(featuresByRegionIds(geo, codes));
if (!geo || !idKey || !src || cancelled) return;
src.setData(featuresByRegionKeys(geo, codes, idKey));
if (!map.getLayer(FLASH_LINE)) {
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
// hover highlight when that layer exists; otherwise just append.
@@ -355,10 +374,10 @@ export default function WorldMap({
// The sub-region a hovered/clicked feature belongs to is identified by its
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
// and its **owner** is resolved from the authoritative RegionMap via that
// same `properties.regionId` through `mapFeatureOwner` — since v0.19 the
// RegionMap is keyed by the sub-region's own id. Never the repacked
// top-level `id` (a numeric index, #6).
// and its **owner** is resolved from the authoritative RegionMap via the
// feature's stamped `properties.regionKey` through
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
// index, #6) and never from the raw sub-region id (not a RegionMap key).
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
@@ -392,22 +411,19 @@ export default function WorldMap({
};
// #7: a click does NOT open a popup — it switches the dossier to the region's
// owning nation. Since v0.19 the owner is the nation that owns **this**
// sub-region, resolved from `properties.regionId` via the RegionMap (never
// the repacked top-level `f.id`, #6). A sub-region with no owner leaves the
// current dossier target unchanged — never a broken dossier.
// owning nation, resolved from the feature's stamped `properties.regionKey`
// via the RegionMap (never the repacked top-level `f.id`, #6, and never the
// raw sub-region id). A sub-region whose key has no owner leaves the current
// dossier target unchanged — never a broken dossier.
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
const region = mapFeatureOwner(regionsRef.current, f);
if (!region?.ownerId) return;
// Carry the clicked sub-region's identity (own id/name + parent ISO_A3) so
// the dossier can show which region was selected.
const p = f.properties as { regionId?: string; parent?: string; name?: string };
const info = {
parent: typeof p?.parent === 'string' ? p.parent : undefined,
name: typeof p?.name === 'string' ? p.name : undefined,
};
// Carry the clicked sub-region's name so the dossier can show which region
// was selected.
const p = f.properties as { name?: string };
const info = { name: typeof p?.name === 'string' ? p.name : undefined };
onFocusNationRef.current?.(region.ownerId, info);
};
@@ -435,34 +451,40 @@ export default function WorldMap({
}
};
// Stamp ownership + colors onto the regions source (no-op until geo loads).
// Stamp ownership + colors onto the regions source (no-op until both the
// base geo and the region-key manifest are loaded).
const paintRegions = () => {
const geo = baseGeoRef.current;
const idKey = idKeyRef.current;
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (!geo || !src) return;
src.setData(applyRegions(geo, regionsRef.current, playerRef.current));
if (!geo || !idKey || !src) return;
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey));
};
const ensureLayers = async () => {
if (cancelled) return;
ensureSources();
if (baseGeoRef.current) {
paintRegions();
} else {
if (!baseGeoRef.current) {
try {
await loadGeo();
} catch (err) {
console.error('failed to load regions geojson', err);
}
if (cancelled) return;
paintRegions();
}
if (!idKeyRef.current) {
try {
await loadIdKey();
} catch (err) {
console.error('failed to load region keys', err);
}
}
if (cancelled) return;
paintRegions();
if (!map.getLayer(FILL_LAYER)) {
// The choropleth is the star: a high opacity so the vivid per-nation
// colors read clearly even on the dark background / over the terrain.
// Since v0.19 each sub-region carries the color of the nation owning
// that exact sub-region, so a country taken at region level shows the
// split directly in the fill.
// Each sub-region carries its own owning nation's color (v0.19 #16 P1),
// so the fill traces region-key ownership, not country borders.
map.addLayer({
id: FILL_LAYER,
type: 'fill',
@@ -542,28 +564,30 @@ export default function WorldMap({
};
}, []);
// Re-color the choropleth whenever ownership changes, and flash any sub-region
// whose owner changed since the last update (a transient, self-cleaning pulse).
// The diff is over the sub-region-id-keyed RegionMap; `animateFlash` lights up
// exactly the features whose own id changed owner.
// Re-color the choropleth whenever ownership changes, and flash each
// sub-region whose owner changed since the last update (a transient,
// self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19
// #16 P1); `animateFlash` lights up exactly the sub-region for each changed
// key.
useEffect(() => {
const map = mapRef.current;
const geo = baseGeoRef.current;
const idKey = idKeyRef.current;
const cur = regions;
const prev = prevRegionsRef.current;
prevRegionsRef.current = cur;
const wasPrimed = primedRef.current;
if (Object.keys(cur).length > 0) primedRef.current = true;
if (map && geo) {
if (map && geo && idKey) {
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
if (src) void src.setData(applyRegions(geo, cur, playerNationId));
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey));
}
if (wasPrimed && prev) {
if (wasPrimed && prev && idKey) {
const changed = new Set<string>();
for (const regionId of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
if ((prev[regionId]?.ownerId ?? '') !== (cur[regionId]?.ownerId ?? '')) changed.add(regionId);
for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key);
}
if (changed.size > 0) flashApiRef.current?.([...changed]);
}
+46
View File
@@ -0,0 +1,46 @@
// Client-side region-key data access. Fetches the two static JSON files served
// from public/ and builds the lookups the region-level UI needs:
// - idToKey(): feature id -> region key (to stamp each map feature + resolve owner)
// - keyToName(): region key -> human name (to show region names in the dossier)
// Each is fetched once and cached (Promise singleton) so WorldMap and NationPanel
// share a single fetch. Same-origin relative URLs (proxy/Traefik safe) — never a
// hardcoded origin.
type GeoLike = {
features?: Array<{ id?: string | number; properties?: { name?: string } }>;
};
let idKeyPromise: Promise<Record<string, string>> | null = null;
let keyNamePromise: Promise<Record<string, string>> | null = null;
async function fetchJson<T>(url: string): Promise<T> {
const r = await fetch(url);
if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
return (await r.json()) as T;
}
/** feature id (from regions.geo.json) -> region key (from region-keys.json). */
export function idToKey(): Promise<Record<string, string>> {
idKeyPromise ??= fetchJson<Record<string, string>>('/region-keys.json');
return idKeyPromise;
}
/**
* region key -> human region name. Built by joining region-keys.json (id -> key)
* with regions.geo.json (id -> properties.name). A key with no known name is
* simply absent (callers fall back to the raw key).
*/
export function keyToName(): Promise<Record<string, string>> {
keyNamePromise ??= Promise.all([idToKey(), fetchJson<GeoLike>('/regions.geo.json')]).then(
([keys, geo]) => {
const out: Record<string, string> = {};
for (const f of geo.features ?? []) {
const id = f.id == null ? '' : String(f.id);
const key = keys[id];
const name = f.properties?.name;
if (key && typeof name === 'string') out[key] = name;
}
return out;
},
);
return keyNamePromise;
}