Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99ee93f6da | ||
|
|
b9ac79c57f | ||
|
|
1c64aa1da4 | ||
|
|
29927bae34 | ||
|
|
325bdc5255 | ||
|
|
2cfa2f78f9 | ||
|
|
2b84c34f41 |
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
@@ -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
@@ -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(', ')}`);
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -318,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', () => {
|
||||
@@ -461,6 +472,41 @@ describe('applyTurnResult', () => {
|
||||
expect(state.pending).toHaveLength(1); // original untouched
|
||||
expect(state.version).toBe(1);
|
||||
});
|
||||
|
||||
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: [],
|
||||
// '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, 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('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: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||
};
|
||||
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||
expect(next.nations.de.territory).toContain('POL');
|
||||
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful', () => {
|
||||
|
||||
+26
-13
@@ -53,11 +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":["<ISO_A3>"],"lose":["<ISO_A3>"]}]',
|
||||
' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}]',
|
||||
'}',
|
||||
'',
|
||||
'HARD CONSTRAINTS',
|
||||
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
|
||||
'- 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.',
|
||||
@@ -75,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) =>
|
||||
@@ -88,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,
|
||||
@@ -110,6 +115,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
|
||||
pending: items,
|
||||
roundInstruction,
|
||||
};
|
||||
if (regionReference) payload.regions = regionReference;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
@@ -283,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
|
||||
@@ -309,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);
|
||||
}
|
||||
}
|
||||
@@ -367,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.';
|
||||
|
||||
@@ -398,7 +411,7 @@ export async function runTurn(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
return { state: applyTurnResult(state, result), result };
|
||||
return { state: applyTurnResult(state, result, validRegionKeys), result };
|
||||
}
|
||||
|
||||
// --- Advisor (brainstorm) -----------------------------------------------------
|
||||
|
||||
@@ -20,6 +20,7 @@ import { createLlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
||||
import { buildRegionMap } from './map';
|
||||
import { allTerritoryKeys, expandCountriesToKeys, loadRegions, referenceForKeys, validKeys } from './regions';
|
||||
import { createPresetStore } from './presets';
|
||||
import { openDb, type Db } from './db';
|
||||
|
||||
@@ -49,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,6 +125,10 @@ app.post('/api/games', (req: Request, res) => {
|
||||
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 });
|
||||
});
|
||||
@@ -227,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 });
|
||||
|
||||
+40
-35
@@ -3,6 +3,10 @@ import { buildRegionMap } from './map';
|
||||
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
// 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',
|
||||
@@ -16,7 +20,7 @@ const state: GameState = {
|
||||
name: 'UK',
|
||||
type: 'major',
|
||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||
territory: ['GBR', 'IRL'],
|
||||
territory: ['albania-aaaa', 'wales-bbbb'],
|
||||
relations: {},
|
||||
},
|
||||
de: {
|
||||
@@ -24,7 +28,7 @@ const state: GameState = {
|
||||
name: 'DE',
|
||||
type: 'major',
|
||||
stats: { military: 90, economy: 70, stability: 70, population: 90 },
|
||||
territory: ['DEU'],
|
||||
territory: ['brandenburg-cccc'],
|
||||
relations: {},
|
||||
},
|
||||
},
|
||||
@@ -33,28 +37,28 @@ const state: GameState = {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
// The RegionMap built from the fixture: the authoritative ISO_A3 → owner source.
|
||||
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||
const regions: RegionMap = buildRegionMap(state);
|
||||
|
||||
describe('buildRegionMap', () => {
|
||||
it('maps each ISO_A3 region 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.GBR).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||
expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||
expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
||||
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.FRA).toBeUndefined();
|
||||
expect(m['normandie-dddd']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('regionOwner', () => {
|
||||
it('resolves an owned region\'s ISO_A3 to its owner record', () => {
|
||||
expect(regionOwner(regions, 'GBR')).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, 'DEU')).toEqual({
|
||||
expect(regionOwner(regions, 'brandenburg-cccc')).toEqual({
|
||||
color: nationColor('de'),
|
||||
owner: 'DE',
|
||||
ownerId: 'de',
|
||||
@@ -62,11 +66,11 @@ describe('regionOwner', () => {
|
||||
});
|
||||
|
||||
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
||||
expect(regionOwner(regions, 'FRA')).toBeUndefined();
|
||||
expect(regionOwner(regions, 'USA')).toBeUndefined();
|
||||
expect(regionOwner(regions, 'normandie-dddd')).toBeUndefined();
|
||||
expect(regionOwner(regions, 'yorkshire-eeee')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an empty or missing code', () => {
|
||||
it('returns undefined for an empty or missing key', () => {
|
||||
expect(regionOwner(regions, '')).toBeUndefined();
|
||||
expect(regionOwner(regions, null)).toBeUndefined();
|
||||
expect(regionOwner(regions, undefined)).toBeUndefined();
|
||||
@@ -74,28 +78,29 @@ describe('regionOwner', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* #6 regression guard (sub-national map). 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 now sub-national: a feature's id is a sub-region code
|
||||
* (`DEU-03`) and its owner lives at the *parent country* (`DEU`), which the
|
||||
* client stamps into `properties.parent` (the RegionMap is keyed by that
|
||||
* country code, never the sub-region id). This pins the exact data shape and
|
||||
* proves the resolution: `mapFeatureOwner` works via `properties.parent`, while
|
||||
* the two wrong paths (the repacked numeric `feature.id`, and the sub-region
|
||||
* `regionId`, which is not a RegionMap key) do 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 — sub-region owner must be resolved from properties.parent, not the sub-region id / 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 code) and `parent` (the ISO_A3 country).
|
||||
// `regionId` (the sub-region code) and `regionKey` (the region key).
|
||||
const repackedFeature = {
|
||||
id: 0,
|
||||
properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' },
|
||||
properties: { name: 'Brandenburg R03', regionId: 'DEU-03', regionKey: 'brandenburg-cccc' },
|
||||
};
|
||||
|
||||
it('resolves the owner from properties.parent (the fix)', () => {
|
||||
it('resolves the owner from properties.regionKey (the fix)', () => {
|
||||
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
||||
color: nationColor('de'),
|
||||
owner: 'DE',
|
||||
@@ -104,7 +109,7 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t
|
||||
});
|
||||
|
||||
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 the parent 'DEU'.
|
||||
// 'DEU-03' is a sub-region id; the RegionMap is keyed by region key.
|
||||
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -116,17 +121,17 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t
|
||||
});
|
||||
|
||||
describe('mapFeatureOwner', () => {
|
||||
it('returns undefined for a missing feature, missing parent, or non-string parent', () => {
|
||||
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: { parent: 42 } })).toBeUndefined();
|
||||
expect(mapFeatureOwner(regions, { properties: { regionKey: 42 } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a parent country nobody owns', () => {
|
||||
expect(mapFeatureOwner(regions, { properties: { parent: 'FRA' } })).toBeUndefined();
|
||||
expect(mapFeatureOwner(regions, { properties: { parent: 'USA' } })).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
-5
@@ -1,6 +1,8 @@
|
||||
// Build the choropleth payload from a GameState: every ISO_A3 region maps to its
|
||||
// owning nation plus that nation's stable color (see shared.nationColor). The
|
||||
// client merges this onto public/countries.geo.json and calls source.setData().
|
||||
// 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';
|
||||
|
||||
@@ -8,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 code of nation.territory) {
|
||||
map[code] = { color, owner: nation.name, ownerId: id };
|
||||
for (const key of nation.territory) {
|
||||
map[key] = { color, owner: nation.name, ownerId: id };
|
||||
}
|
||||
}
|
||||
return map;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
allTerritoryKeys,
|
||||
expandCountriesToKeys,
|
||||
hasRegionData,
|
||||
loadRegions,
|
||||
referenceForKeys,
|
||||
regionName,
|
||||
resetRegions,
|
||||
validKeys,
|
||||
} from './regions';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
// 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));
|
||||
|
||||
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,
|
||||
};
|
||||
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||
expect(allTerritoryKeys(state).size).toBe(58);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => resetRegions());
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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:
|
||||
//
|
||||
// - 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)
|
||||
//
|
||||
// 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';
|
||||
|
||||
/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */
|
||||
interface RegionInfo {
|
||||
name: string;
|
||||
parent: string;
|
||||
}
|
||||
|
||||
interface RegionData {
|
||||
/** stable feature id -> region key (from public/region-keys.json). */
|
||||
idToKey: Record<string, string>;
|
||||
/** region key -> { name, parent }. */
|
||||
keyToInfo: Record<string, RegionInfo>;
|
||||
/** ISO_A3 parent -> region keys of its sub-regions (file order, stable). */
|
||||
keysByCountry: Record<string, string[]>;
|
||||
/** the set of all region keys. */
|
||||
validKeys: Set<string>;
|
||||
}
|
||||
|
||||
let data: RegionData | null = null;
|
||||
|
||||
/**
|
||||
* Load (or reload) the region-key manifest + geo from `root`. Idempotent.
|
||||
* Returns true when both files loaded cleanly. A missing/broken file clears the
|
||||
* data and returns false, so the server still boots and degrades gracefully
|
||||
* (expansion becomes a no-op, ownership falls back to the raw input keys).
|
||||
*/
|
||||
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 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 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 key = idToKey[id];
|
||||
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||
const name = typeof f.properties?.name === 'string' ? f.properties.name : '';
|
||||
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
|
||||
keyToInfo[key] = { name, parent };
|
||||
validKeys.add(key);
|
||||
if (parent) (keysByCountry[parent] ??= []).push(key);
|
||||
}
|
||||
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 nation's ISO_A3 country list to the region keys of all its sub-regions
|
||||
* (file order, deduped). This is what turns a preset's country-level territory
|
||||
* into region-level ownership at game creation. Returns the input unchanged when
|
||||
* no region data is loaded (graceful degradation — behaves like the old model).
|
||||
*/
|
||||
export function expandCountriesToKeys(countries: string[]): string[] {
|
||||
if (!data) return countries;
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const c of countries) {
|
||||
for (const key of data.keysByCountry[c] ?? []) {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
out.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the LLM reference table: region key -> "<name> (<parent ISO_A3>)", e.g.
|
||||
* `moselle-rygy -> "Moselle (FRA)"`. Only the given keys are included (the caller
|
||||
* passes the game's owned-region universe) so the prompt stays bounded.
|
||||
*/
|
||||
export function referenceForKeys(keys: Iterable<string>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!data) return out;
|
||||
for (const key of keys) {
|
||||
const info = data.keyToInfo[key];
|
||||
if (info) out[key] = info.parent ? `${info.name} (${info.parent})` : info.name;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The region key's human name (for the dossier), or undefined if unknown. */
|
||||
export function regionName(key: string): string | undefined {
|
||||
return data?.keyToInfo[key]?.name;
|
||||
}
|
||||
|
||||
/** The union of every nation's territory region keys (the game's region universe). */
|
||||
export function allTerritoryKeys(state: GameState): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const n of Object.values(state.nations)) {
|
||||
for (const key of n.territory) out.add(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The set of all valid region keys (to ignore hallucinated keys in applyTurnResult). */
|
||||
export function validKeys(): ReadonlySet<string> {
|
||||
return data ? data.validKeys : new Set<string>();
|
||||
}
|
||||
+27
-20
@@ -23,7 +23,7 @@ export interface RelationChange {
|
||||
set: RelationStatus;
|
||||
}
|
||||
|
||||
/** A nation gains/loses ISO_A3 regions this turn. */
|
||||
/** A nation gains/loses region keys (see `public/region-keys.json`) this turn. */
|
||||
export interface TerritoryChange {
|
||||
nation: string;
|
||||
gain: string[];
|
||||
@@ -48,26 +48,32 @@ export interface TurnResult {
|
||||
territoryChanges: TerritoryChange[];
|
||||
}
|
||||
|
||||
/** Per-region map payload: ISO_A3 -> 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 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 **parent country's** ISO_A3 code (ownership and
|
||||
* the LLM stay at country granularity — see #16), so `key` is a country code such
|
||||
* as `DEU`, never a sub-region id such as `DEU-03`. Returns `undefined` for an
|
||||
* empty/missing key or an unowned country.
|
||||
* 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.parent` — never the repacked top-level
|
||||
* `id` (MapLibre turns it into a numeric index, the "no owner" bug #6) and never
|
||||
* the sub-region id.
|
||||
* 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;
|
||||
@@ -76,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. The choropleth renders sub-national regions, but the
|
||||
* RegionMap is keyed by the *parent country's* ISO_A3 (not the sub-region's own
|
||||
* id), so a feature's owner is found via its `properties.parent` (the ISO_A3 the
|
||||
* client stamps from the GeoJSON `parent` property — see WorldMap) and then
|
||||
* `regionOwner`. This is the single shared source of truth for that resolution,
|
||||
* so the "use `parent`, never the sub-region `id`/repacked `feature.id`" 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/parent or an unowned country.
|
||||
* Returns `undefined` for a missing feature/regionKey or an unowned region.
|
||||
*/
|
||||
export function mapFeatureOwner(
|
||||
regions: RegionMap,
|
||||
feature: { properties?: { parent?: unknown } } | null | undefined,
|
||||
feature: { properties?: { regionKey?: unknown } } | null | undefined,
|
||||
): RegionOwner | undefined {
|
||||
const parent = feature?.properties?.parent;
|
||||
return regionOwner(regions, typeof parent === 'string' ? parent : '');
|
||||
const regionKey = feature?.properties?.regionKey;
|
||||
return regionOwner(regions, typeof regionKey === 'string' ? regionKey : '');
|
||||
}
|
||||
|
||||
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||||
|
||||
+4
-2
@@ -26,8 +26,10 @@ export const RELATION_STATUSES: RelationStatus[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* A single nation in the world. `territory` holds ISO_A3 codes; the map colors
|
||||
* each of those regions with this nation's stable color (see protocol.nationColor).
|
||||
* 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
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
+111
-82
@@ -11,6 +11,7 @@ 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
|
||||
@@ -101,26 +102,32 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
|
||||
|
||||
/**
|
||||
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
|
||||
* Ownership and the LLM stay at **country** granularity (#16, PR 2): a nation
|
||||
* owns whole countries, so every sub-region of a country shares that country's
|
||||
* owning nation's color — the map looks like the old country choropleth, but
|
||||
* subdivided into finer cells. 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. `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);
|
||||
* - `parent` – the parent country's ISO_A3 (e.g. `FRA`), the RegionMap key;
|
||||
* - `fill` – the owning nation's stable color (from `RegionMap[parent]`);
|
||||
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned country);
|
||||
* - `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 `properties.parent` (the
|
||||
* country ISO_A3), **never** by the sub-region id — the RegionMap has no
|
||||
* `FRA-03` entries, only `FRA`.
|
||||
* 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 ?? {};
|
||||
@@ -128,11 +135,11 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
|
||||
// survives into the rendered features (the top-level `id` gets repacked).
|
||||
const subId = f.id == null ? '' : String(f.id);
|
||||
f.properties.regionId = subId;
|
||||
// Ownership lives at the parent country: resolve `properties.parent`
|
||||
// (the ISO_A3) against the RegionMap. Never the sub-region id.
|
||||
const parent = typeof f.properties.parent === 'string' ? f.properties.parent : '';
|
||||
f.properties.parent = parent;
|
||||
const r = parent ? regions[parent] : undefined;
|
||||
// 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;
|
||||
@@ -151,16 +158,21 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a FeatureCollection of just the sub-regions whose **parent** country is
|
||||
* in `parents` (cloned). Used by the territory-change flash: a turn changes a
|
||||
* whole *country* (the RegionMap is ISO_A3-keyed), so a changed country's code
|
||||
* must light up **all** of its sub-regions, not a single feature.
|
||||
* 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 featuresByParents(geo: FeatureCollection, parents: Iterable<string>): FeatureCollection {
|
||||
const set = new Set(parents);
|
||||
function featuresByRegionKeys(
|
||||
geo: FeatureCollection,
|
||||
keys: Iterable<string>,
|
||||
idKey: Record<string, string>,
|
||||
): FeatureCollection {
|
||||
const set = new Set(keys);
|
||||
const features = geo.features.filter((f) => {
|
||||
const parent = f.properties?.parent;
|
||||
return typeof parent === 'string' && set.has(parent);
|
||||
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)) };
|
||||
}
|
||||
@@ -203,37 +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. For a sub-region, the owner is the parent
|
||||
* country's owning nation. The optional `info` carries the clicked sub-region's
|
||||
* own id/name and its parent country's ISO_A3, 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.
|
||||
* 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. Ownership and the LLM stay at **country** granularity (#16, PR 2): a
|
||||
* nation owns whole countries, so all of a country's sub-regions share that
|
||||
* country's owning-nation color. 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 (uniform per country, the choropleth is the
|
||||
* star), thin sub-region borders, a brighter/thicker amber border for the
|
||||
* player's countries, a hover tooltip (sub-region name + current owner
|
||||
* + key stats), a **click that switches the dossier to the sub-region's parent
|
||||
* country's owning nation** (no popup — see #7), a transient flash when a
|
||||
* country's owner changes (lighting up all its sub-regions), 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 its **parent country's** ISO_A3 code (`properties.parent`, 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) and
|
||||
* never from the sub-region id (not a RegionMap key). Unowned countries (no
|
||||
* RegionMap entry for the parent) simply do not change the dossier.
|
||||
* 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.
|
||||
@@ -249,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);
|
||||
@@ -304,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
|
||||
// parent-country ISO_A3 codes (a turn changes whole countries); each lights
|
||||
// up all of that country's sub-regions.
|
||||
// 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(featuresByParents(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.
|
||||
@@ -350,9 +375,9 @@ 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 the
|
||||
// feature's `properties.parent` (the parent country's ISO_A3) through
|
||||
// feature's stamped `properties.regionKey` through
|
||||
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
|
||||
// index, #6) and never from the sub-region id (not a RegionMap key).
|
||||
// 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;
|
||||
@@ -386,23 +411,19 @@ export default function WorldMap({
|
||||
};
|
||||
|
||||
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
||||
// owning nation. For a sub-region, the owner is the **parent country's**
|
||||
// owning nation, resolved from `properties.parent` (the ISO_A3) via the
|
||||
// RegionMap (never the repacked top-level `f.id`, #6, and never the
|
||||
// sub-region id). A sub-region whose parent country has 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);
|
||||
};
|
||||
|
||||
@@ -430,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.
|
||||
// Every sub-region carries its parent country's owner color, so a whole
|
||||
// country reads as one uniform fill — same as the old country map, but
|
||||
// subdivided.
|
||||
// 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',
|
||||
@@ -537,28 +564,30 @@ export default function WorldMap({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-color the choropleth whenever ownership changes, and flash any country
|
||||
// (all of its sub-regions) whose owner changed since the last update (a
|
||||
// transient, self-cleaning pulse). The diff is over the ISO_A3-keyed RegionMap;
|
||||
// `animateFlash` expands each changed country to every one of its sub-regions.
|
||||
// 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 code of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
|
||||
if ((prev[code]?.ownerId ?? '') !== (cur[code]?.ownerId ?? '')) changed.add(code);
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user