From fdf6eb236adb71b3fc785c54ff644dcd82d5456e Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 13 Oct 2026 22:07:18 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20sub-region-level=20territory=20?= =?UTF-8?q?=E2=80=94=20ownership,=20LLM=20and=20map=20at=20region=20granul?= =?UTF-8?q?arity=20(v0.19.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move territory from whole-country (ISO_A3) to individual sub-region id granularity (PR 3 of #16): - new server/regions.ts (turf-free): loadRegions (boot), expandTerritory (game creation), migrateTerritoryToRegions (legacy games on db.load), regionNamesFor / regionLabel (LLM prompt), graceful ISO_A3 fallback - game creation expands each preset nation's ISO_A3 list to its sub-region ids; pre-v0.19 saved games migrate transparently on read (idempotent) - LLM contract: territoryChanges gain/lose sub-region ids (one region at a time; gain moves out of the prior owner; lost-without-gainer = unowned); user prompt carries a top-level regions reference table ({id: 'name (parent)'} over the state's territory ids) - WorldMap: choropleth fill per sub-region (countries can split between nations), ownership via properties.regionId through the shared mapFeatureOwner, flash lights exactly the changed features (featuresByRegionIds) - deterministic stub: one region-level move per non-empty round (player annexes the lexicographically-first region of the first other nation) - tests: new server/regions.test.ts (real geo + fallback + seams); map/engine tests re-pinned to region ids; scripts/regions.test.ts stays green Measured full wwii-1939 user prompt (411 regions): ~24.7k chars / ~6.2k tokens — far under the 80k frontier limit, so the full regions table is always sent (no frontier-limited mode needed). Part of #16 --- CHANGELOG.md | 10 ++ package.json | 2 +- server/db.ts | 7 +- server/engine.test.ts | 116 +++++++++++++++++++--- server/engine.ts | 15 ++- server/index.ts | 16 ++- server/map.test.ts | 87 +++++++++-------- server/map.ts | 14 ++- server/regions.test.ts | 176 +++++++++++++++++++++++++++++++++ server/regions.ts | 187 ++++++++++++++++++++++++++++++++++++ server/stub.ts | 15 ++- shared/protocol.ts | 51 ++++++---- shared/types.ts | 11 ++- src/components/WorldMap.tsx | 153 +++++++++++++++-------------- 14 files changed, 698 insertions(+), 162 deletions(-) create mode 100644 server/regions.test.ts create mode 100644 server/regions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f63128..e97c3d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 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.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. diff --git a/package.json b/package.json index 25bbe35..f1d8d7c 100644 --- a/package.json +++ b/package.json @@ -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).", diff --git a/server/db.ts b/server/db.ts index bc1574e..6358bd9 100644 --- a/server/db.ts +++ b/server/db.ts @@ -3,6 +3,7 @@ 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; @@ -39,7 +40,11 @@ export function openDb(filePath: string): Db { const row = byId.get(id) as { data: string } | undefined; if (!row) return null; try { - return JSON.parse(row.data) as GameState; + // 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); } catch { return null; } diff --git a/server/engine.test.ts b/server/engine.test.ts index a7f55f6..6134fd7 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { beforeAll, 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, @@ -24,6 +25,27 @@ 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', @@ -37,7 +59,7 @@ const preset: Preset = { name: 'UK', type: 'major', stats: { military: 80, economy: 70, stability: 60, population: 50 }, - territory: ['GBR'], + territory: ['GB-01', 'GB-02'], relations: { us: 'neutral', de: 'hostile' }, }, { @@ -45,7 +67,7 @@ const preset: Preset = { name: 'US', type: 'major', stats: { military: 60, economy: 90, stability: 80, population: 80 }, - territory: ['USA'], + territory: ['US-01', 'US-02', 'PL-01'], relations: { uk: 'neutral' }, }, { @@ -53,7 +75,7 @@ const preset: Preset = { name: 'DE', type: 'major', stats: { military: 95, economy: 70, stability: 75, population: 90 }, - territory: ['DEU'], + territory: ['DE-01'], relations: {}, }, ], @@ -88,7 +110,7 @@ const validResult = JSON.stringify({ { nation: 'de', stat: 'economy', op: 'add', value: 10 }, ], relations: [{ a: 'uk', b: 'us', set: 'ally' }], - territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }], + territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }], }); describe('runTurn', () => { @@ -109,8 +131,9 @@ describe('runTurn', () => { // relations are symmetric expect(state.nations.uk.relations.us).toBe('ally'); expect(state.nations.us.relations.uk).toBe('ally'); - // territory moved to DE - expect(state.nations.de.territory).toContain('POL'); + // 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'); // one event appended, tagged with the turn that was resolved (1) expect(state.events).toHaveLength(1); expect(state.events[0].turn).toBe(1); @@ -121,7 +144,8 @@ 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.de.territory).not.toContain('POL'); + expect(original.nations.us.territory).toContain('PL-01'); + expect(original.nations.de.territory).not.toContain('PL-01'); }); it('clamps out-of-range stat values into 0-100', async () => { @@ -191,7 +215,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)', async () => { + 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 () => { const original = baseState(); original.pending = [{ kind: 'action', text: 'Fortify the coast and mobilize the home guard' }]; const { state, result } = await runTurn({ @@ -209,6 +233,12 @@ 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) @@ -216,7 +246,7 @@ describe('runTurn (batched round, deterministic stub)', () => { expect(original.version).toBe(1); }); - it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => { + it('an empty round: turn advances, one political "world moves on" event, no territory move', async () => { const { state } = await runTurn({ llm: makeStubLlmClient(), preset, @@ -230,9 +260,13 @@ 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', async () => { + it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance, one region gain', async () => { const state = baseState(); state.pending = [ { kind: 'action', text: 'Reinforce the Atlantic convoys' }, @@ -251,6 +285,9 @@ 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']); }); }); @@ -264,6 +301,7 @@ describe('buildUserPrompt (batched round)', () => { pending: RoundItem[]; playerActions: unknown[]; roundInstruction: string; + regions: Record; }; expect(parsed.pending).toEqual(items); expect(parsed.playerActions).toEqual(['Fortify Dover', { to: 'us', message: 'Propose a pact' }]); @@ -271,6 +309,20 @@ 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; + state: { nations: Record }; + }; + // 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[]; @@ -461,6 +513,48 @@ describe('applyTurnResult', () => { expect(state.pending).toHaveLength(1); // original untouched expect(state.version).toBe(1); }); + + it('moves a gained region out of its prior owner (region-level transfer)', () => { + const state = baseState(); + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + relations: [], + territoryChanges: [{ nation: 'de', gain: ['PL-01'], 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']); + }); + + it('adds a region nobody owned (no prior owner to splice from)', () => { + const state = baseState(); + const result: TurnResult = { + date: '1939-09-03', + events: [], + stateChanges: [], + relations: [], + territoryChanges: [{ nation: 'uk', gain: ['FR-57'], 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); + }); }); describe('isMeaningful', () => { diff --git a/server/engine.ts b/server/engine.ts index c10e596..f7535cc 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -24,6 +24,7 @@ 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(EVENT_TYPES); const VALID_RELATIONS = new Set(RELATION_STATUSES); @@ -53,11 +54,12 @@ export function buildSystemPrompt(preset: Preset): string { ' ],', ' "stateChanges": [{"nation":"","stat":"","op":"","value":<0-100>}],', ' "relations": [{"a":"","b":"","set":""}],', - ' "territoryChanges": [{"nation":"","gain":[""],"lose":[""]}]', + ' "territoryChanges": [{"nation":"","gain":[""],"lose":[""]}]', '}', '', 'HARD CONSTRAINTS', - '- Use only nation ids and ISO_A3 codes that exist in the provided state.', + '- 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.', '- 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.', @@ -106,6 +108,15 @@ 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, diff --git a/server/index.ts b/server/index.ts index 6ea59e3..4e1312f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -19,6 +19,7 @@ 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 { createPresetStore } from './presets'; import { openDb, type Db } from './db'; @@ -41,6 +42,10 @@ 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'); @@ -116,7 +121,16 @@ app.post('/api/games', (req: Request, res) => { playerNationId: nation.id, turn: 1, date: preset.startDate, - nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])), + // 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]; + }), + ), events: [], pending: [], version: 1, // the first saved revision; every later mutation bumps it diff --git a/server/map.test.ts b/server/map.test.ts index 31338ae..28aa254 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -3,6 +3,8 @@ 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. const state: GameState = { id: 'g', presetId: 'p', @@ -16,7 +18,7 @@ const state: GameState = { name: 'UK', type: 'major', stats: { military: 80, economy: 70, stability: 60, population: 50 }, - territory: ['GBR', 'IRL'], + territory: ['GB-01', 'GB-02'], relations: {}, }, de: { @@ -24,7 +26,7 @@ const state: GameState = { name: 'DE', type: 'major', stats: { military: 90, economy: 70, stability: 70, population: 90 }, - territory: ['DEU'], + territory: ['DE-01'], relations: {}, }, }, @@ -33,28 +35,29 @@ 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 sub-region id → 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 sub-region id 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' }); - // a region nobody owns is simply absent - expect(m.FRA).toBeUndefined(); + 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(); }); }); describe('regionOwner', () => { - it('resolves an owned region\'s ISO_A3 to its owner record', () => { - expect(regionOwner(regions, 'GBR')).toEqual({ + it('resolves an owned sub-region id to its owner record', () => { + expect(regionOwner(regions, 'GB-01')).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk', }); - expect(regionOwner(regions, 'DEU')).toEqual({ + expect(regionOwner(regions, 'DE-01')).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de', @@ -62,11 +65,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, 'FR-57')).toBeUndefined(); + expect(regionOwner(regions, 'US-01')).toBeUndefined(); }); - it('returns undefined for an empty or missing code', () => { + it('returns undefined for an empty or missing id', () => { expect(regionOwner(regions, '')).toBeUndefined(); expect(regionOwner(regions, null)).toBeUndefined(); expect(regionOwner(regions, undefined)).toBeUndefined(); @@ -74,28 +77,28 @@ 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, 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. */ -describe('#6 — sub-region owner must be resolved from properties.parent, not the sub-region id / repacked feature.id', () => { +describe('#6 — region owner must be resolved from properties.regionId, never the 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). + // 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). const repackedFeature = { id: 0, - properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' }, + properties: { name: 'Rhineland', regionId: 'DE-01', parent: 'DEU' }, }; - it('resolves the owner from properties.parent (the fix)', () => { + it('resolves the owner from properties.regionId (the fix)', () => { expect(mapFeatureOwner(regions, repackedFeature)).toEqual({ color: nationColor('de'), owner: 'DE', @@ -103,30 +106,30 @@ 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'. - expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined(); - }); - - it('the repacked numeric feature.id does NOT resolve — the bug', () => { + it('the repacked numeric feature.id does NOT resolve — the original 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 parent, or non-string parent', () => { + it('returns undefined for a missing feature, missing regionId, or non-string regionId', () => { 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: { regionId: 42 } })).toBeUndefined(); + expect(mapFeatureOwner(regions, { properties: { regionId: '' } })).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 sub-region nobody owns', () => { + expect(mapFeatureOwner(regions, { properties: { regionId: 'FR-57' } })).toBeUndefined(); + expect(mapFeatureOwner(regions, { properties: { regionId: 'US-01' } })).toBeUndefined(); }); }); diff --git a/server/map.ts b/server/map.ts index df687e2..d9375bb 100644 --- a/server/map.ts +++ b/server/map.ts @@ -1,6 +1,10 @@ -// 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 **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). import { nationColor, type RegionMap } from '../shared/protocol'; import type { GameState } from '../shared/types'; @@ -8,8 +12,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 regionId of nation.territory) { + map[regionId] = { color, owner: nation.name, ownerId: id }; } } return map; diff --git a/server/regions.test.ts b/server/regions.test.ts new file mode 100644 index 0000000..f14e6e1 --- /dev/null +++ b/server/regions.test.ts @@ -0,0 +1,176 @@ +/** + * 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 { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { GameState } from '../shared/types'; +import { + _resetRegionsForTests, + _setRegionsForTests, + expandTerritory, + hasRegionData, + loadRegions, + migrateTerritoryToRegions, + regionLabel, + regionNamesFor, +} from './regions'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '..'); + +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: {}, + }, + }, + events: [], + pending: [], + version: 1, + }; +} + +describe('server/regions (real geo)', () => { + beforeAll(() => { + _resetRegionsForTests(); + loadRegions(root); + }); + + it('loads public/regions.geo.json (hasRegionData becomes true)', () => { + expect(hasRegionData()).toBe(true); + }); + + 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']); + }); +}); diff --git a/server/regions.ts b/server/regions.ts new file mode 100644 index 0000000..c783784 --- /dev/null +++ b/server/regions.ts @@ -0,0 +1,187 @@ +// 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): +// +// - `regionIndex` : region id → { name, parent } (parent = ISO_A3 country) +// - `regionsByCountry`: ISO_A3 country → array of its region ids +// +// 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. +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: string; + parent: string; +} + +let regionIndex: Record = {}; +let regionsByCountry: Record = {}; +let loaded = false; + +/** True once the region geo has been loaded successfully. */ +export function hasRegionData(): boolean { + return loaded; +} + +/** + * (Re)build the in-memory region maps from the region geo. Idempotent: a second + * call is a no-op. Tries `/public/regions.geo.json` first (dev), then + * `/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. + */ +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; + } + try { + const geo = JSON.parse(readFileSync(file, 'utf8')) as { + features?: Array<{ + id?: string | number; + properties?: { name?: string; parent?: string }; + }>; + }; + const index: Record = {}; + const byCountry: Record = {}; + 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 name = typeof f.properties?.name === 'string' ? f.properties.name : ''; + if (!id || !parent) continue; + index[id] = { name, parent }; + (byCountry[parent] ??= []).push(id); + } + regionIndex = index; + regionsByCountry = byCountry; + loaded = Object.keys(index).length > 0; + } catch (e) { + console.warn('[history] failed to load regions.geo.json (' + file + '):', e); + } +} + +/** + * 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). + */ +export function expandTerritory(countryCodes: string[]): string[] { + if (!loaded) return countryCodes; + const seen = new Set(); + const out: string[] = []; + for (const code of countryCodes) { + for (const id of regionsByCountry[code] ?? []) { + if (!seen.has(id)) { + seen.add(id); + out.push(id); + } + } + } + return out; +} + +/** + * 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. + */ +export function regionNamesFor(ids: Iterable): Record { + const out: Record = {}; + for (const id of ids) { + const info = regionIndex[id]; + if (info) out[id] = `${info.name} (${info.parent})`; + } + 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; +} + +/** 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; + 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(); + const out: string[] = []; + for (const id of merged) { + if (!seen.has(id)) { + seen.add(id); + out.push(id); + } + } + n.territory = out; + } + return state; +} + +// --- 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, + byCountry: Record, +): void { + regionIndex = index; + regionsByCountry = byCountry; + loaded = Object.keys(index).length > 0; +} diff --git a/server/stub.ts b/server/stub.ts index 7e8ee2d..bbd167f 100644 --- a/server/stub.ts +++ b/server/stub.ts @@ -13,7 +13,8 @@ export interface StubPayload { turn: number; date: string; playerNationId: string; - nations: Record }>; + /** As of v0.19 territory is a list of sub-region ids (not ISO_A3 codes). */ + nations: Record; territory?: string[] }>; }; /** The raw pending round the engine emitted (one entry per queued item). */ pending?: RoundItem[]; @@ -30,7 +31,12 @@ 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, advancing the date by 2 days. + * 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. */ export function deterministicTurn(p: StubPayload): TurnResult { const state = p.state; @@ -41,6 +47,9 @@ 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 @@ -76,7 +85,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: [], + territoryChanges: gain.length > 0 ? [{ nation: pid, gain, lose: [] }] : [], }; } diff --git a/shared/protocol.ts b/shared/protocol.ts index c9efdc8..7018120 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -23,7 +23,13 @@ export interface RelationChange { set: RelationStatus; } -/** A nation gains/loses ISO_A3 regions this turn. */ +/** + * 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). + */ export interface TerritoryChange { nation: string; gain: string[]; @@ -48,26 +54,31 @@ export interface TurnResult { territoryChanges: TerritoryChange[]; } -/** Per-region map payload: ISO_A3 -> owning nation + its stable color. */ +/** Per-region map payload: **sub-region id** -> 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. + */ export type RegionMap = Record; /** * 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 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). * * 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. + * 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). */ export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined { if (!key) return undefined; @@ -76,22 +87,22 @@ 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 + * 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. * - * Returns `undefined` for a missing feature/parent or an unowned country. + * Returns `undefined` for a missing feature/regionId or an unowned region. */ export function mapFeatureOwner( regions: RegionMap, - feature: { properties?: { parent?: unknown } } | null | undefined, + feature: { properties?: { regionId?: unknown } } | null | undefined, ): RegionOwner | undefined { - const parent = feature?.properties?.parent; - return regionOwner(regions, typeof parent === 'string' ? parent : ''); + const regionId = feature?.properties?.regionId; + return regionOwner(regions, typeof regionId === 'string' ? regionId : ''); } /** Server -> client Server-Sent Events payload (see server/index.ts /sse). */ diff --git a/shared/types.ts b/shared/types.ts index 90a2db4..140cb9b 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -26,8 +26,15 @@ 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 **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. */ export interface Nation { id: string; diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index 30b407e..204a754 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -14,8 +14,10 @@ import { cn } from '@/lib/utils'; // 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 top-level `id` of the form `-` (e.g. `FRA-03`) and +// a stable top-level `id` (ISO 3166-2 style, e.g. `FR-57`, `US-AK`) 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; @@ -101,38 +103,42 @@ 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: + * 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: * - * - `regionId` – its own sub-region id (e.g. `FRA-03`), kept in a property that + * - `regionId` – its own sub-region id (e.g. `FR-57`), 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); + * (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); * - `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 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. */ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: 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. `FRA-03`) — stamped into a property that + // The sub-region's own id (e.g. `FR-57`) — stamped into a property that // 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 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 = parent ? regions[parent] : undefined; + const r = regionId ? regions[regionId] : undefined; if (r) { f.properties.fill = r.color; f.properties.owner = r.owner; @@ -151,17 +157,15 @@ 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 **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. */ -function featuresByParents(geo: FeatureCollection, parents: Iterable): FeatureCollection { - const set = new Set(parents); - const features = geo.features.filter((f) => { - const parent = f.properties?.parent; - return typeof parent === 'string' && set.has(parent); - }); +function featuresByRegionIds(geo: FeatureCollection, regionIds: Iterable): FeatureCollection { + const set = new Set(regionIds); + const features = geo.features.filter((f) => f.id != null && set.has(String(f.id))); return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) }; } @@ -175,11 +179,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'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. + * (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. */ function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string { if (!region || !nation) { @@ -203,11 +207,12 @@ 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. 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 + * 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; } @@ -216,24 +221,25 @@ interface Props { * 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 + * 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. * * 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 the feature's **own sub-region id** (`properties.regionId`, 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. + * `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. * * Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag) * — no floating control and no external API. @@ -307,14 +313,14 @@ export default function WorldMap({ 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. + // 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. function animateFlash(codes: string[]) { const geo = baseGeoRef.current; const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined; if (!geo || !src || cancelled) return; - src.setData(featuresByParents(geo, codes)); + src.setData(featuresByRegionIds(geo, codes)); 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. @@ -349,10 +355,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 the - // feature's `properties.parent` (the parent country's ISO_A3) through - // `mapFeatureOwner` — never from the repacked top-level `id` (a numeric - // index, #6) and never from the sub-region id (not a RegionMap key). + // 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). const onHover = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; @@ -386,10 +392,9 @@ 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 + // 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. const onClick = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; @@ -455,9 +460,9 @@ export default function WorldMap({ 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. + // 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. map.addLayer({ id: FILL_LAYER, type: 'fill', @@ -537,10 +542,10 @@ 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 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. useEffect(() => { const map = mapRef.current; const geo = baseGeoRef.current; @@ -557,8 +562,8 @@ export default function WorldMap({ if (wasPrimed && prev) { const changed = new Set(); - for (const code of new Set([...Object.keys(prev), ...Object.keys(cur)])) { - if ((prev[code]?.ownerId ?? '') !== (cur[code]?.ownerId ?? '')) changed.add(code); + for (const regionId of new Set([...Object.keys(prev), ...Object.keys(cur)])) { + if ((prev[regionId]?.ownerId ?? '') !== (cur[regionId]?.ownerId ?? '')) changed.add(regionId); } if (changed.size > 0) flashApiRef.current?.([...changed]); }