From 06cdbf3d81752a0cd40351efeb5df754719c1604 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 12 Oct 2026 01:07:33 +0000 Subject: [PATCH] Switch map to sub-national regions (v0.12.0) Part of #16 --- AGENTS.md | 41 +++--- CHANGELOG.md | 8 ++ package.json | 2 +- server/map.test.ts | 62 +++++++--- shared/protocol.ts | 45 +++++-- src/App.tsx | 28 ++++- src/components/WorldMap.tsx | 241 ++++++++++++++++++++++-------------- 7 files changed, 284 insertions(+), 143 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1796abb..486406e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,10 @@ renders territorial ownership; the whole thing persists to SQLite and streams ov - Frontend: React 19 + Vite 6 + Tailwind CSS v4 (`@tailwindcss/vite`, no config file) + shadcn/ui - Backend: Node 22 + Express — one process serving the built client, `/api` (REST), and `/sse` - Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON) -- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a country choropleth - from `public/countries.geo.json` (`id` = ISO_A3) +- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national** + choropleth from `public/regions.geo.json` (2,597 sub-regions; feature `id` = `-`, + `properties.parent` = the country's ISO_A3). Ownership and the LLM stay at **country** + granularity, so a sub-region inherits its parent country's owner color (see #16). - The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an **injectable transport** (mirrors `mesh-bot/src/llm`). A deterministic **stub** transport (`LLM_STUB=1`) is used by all tests/e2e. @@ -48,7 +50,8 @@ history/ ├── index.html ├── presets/ # 5 scenario JSON files + index.json (manifest) ├── public/ -│ └── countries.geo.json # 177 features, id=ISO_A3 (committed; Vite copies to dist/) +│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator) +│ └── regions.geo.json # 2,597 sub-regions, id=-, parent=ISO_A3 (the choropleth) ├── shared/ # NO runtime deps — the single source of truth │ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …) │ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers @@ -140,17 +143,22 @@ history/ (model down) surfaces as an HTTP 502 to the client (shown as a toast). - **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the server's `RegionMap` and any client coloring so the choropleth always agrees. -- **Map click → dossier target (owner resolved from the RegionMap).** Clicking a map region does - NOT open a popup — it switches the dossier to that region's owning nation - (`setViewingId(ownerId)` in `GameScreen`); the manual "Inspect" selector and a map click both set - the same target and stay in sync. The owning nation is resolved from the authoritative `RegionMap` - by the feature's stable ISO_A3 code, which `applyRegions` stamps into `properties.regionId` - (properties survive into the rendered features) and the shared `regionOwner()` helper looks up. - It must **never** be read from the feature's top-level `id`: MapLibre repacks a GeoJSON source's - `id` into a numeric index when it stores the source, so at click/hover time `feature.id` is a - number, never the ISO_A3 (that repacking was the "no owner" bug, #6). Unowned regions (no - `RegionMap` entry) leave the current dossier target unchanged — never a broken dossier. The - hover path (a non-closeable tooltip with owner + key stats) uses the same resolution. +- **Map click → dossier target (owner resolved from the RegionMap, via the parent country).** + Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's + **parent country's** owning nation (`focusNation(ownerId)` in `GameScreen`, which also records + which region was clicked for a small dossier label); the manual "Inspect" selector and a map click + both set the same target and stay in sync (a manual pick clears the label). Ownership is resolved + from the authoritative `RegionMap` (keyed by the **parent country's** ISO_A3) via the feature's + `properties.parent`, using the shared `mapFeatureOwner()` helper (`regionId` still holds the + sub-region's own id, e.g. `FRA-03`). It must **never** be resolved from the feature's top-level + `id` (MapLibre repacks it into a numeric index — the "no owner" bug, #6) nor from the sub-region + `id` (not a `RegionMap` key). Sub-regions of a country with no `RegionMap` entry (unowned) leave + the current dossier target unchanged — never a broken dossier. The hover path (a non-closeable + tooltip showing the sub-region name + parent country + owner + key stats) uses the same resolution. + **PR 2 of #16:** the map renders `regions.geo.json` (sub-national) but ownership/territory and the + LLM stay at country level — a nation owns whole countries, so all its sub-regions share the country's + color, and a whole-country transfer flips all its sub-regions at once (PR 3 will make sub-regions + individually ownable). - **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per queued round item: each `action` item → one military/success event, each `diplomacy` item → one diplomatic/partial event, an empty round → one political "The world moves on" event. The player @@ -239,5 +247,6 @@ history/ caught. - **`better-sqlite3`** is the only native module; the Docker build relies on its prebuilt binary (works on `node:22-slim`/glibc). It's the sole `--external` in the tsup server bundle. -- The `dist/` client is committed to the build output only; `public/countries.geo.json` is copied - into `dist/` by Vite at build time, so the prod image serves it from `dist/` (no separate copy). +- The `dist/` client is committed to the build output only; the `public/` geo files + (`regions.geo.json` — the choropleth — and `countries.geo.json`) are copied into `dist/` by Vite + at build time, so the prod image serves them from `dist/` (no separate copy). diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e9b9b..ce46fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to **History** are listed here, newest first. +## v0.12.0 — 2026-10-12 01:03 +- **Map now renders the sub-national regional dataset (#16, PR 2).** The choropleth source switches from `public/countries.geo.json` (177 countries) to `public/regions.geo.json` (**2,597 sub-regions**): each country is subdivided into ~100–200 km regions, shown as a finer choropleth with thin internal border lines. The MapLibre source/layer ids are renamed `countries`/`countries-fill`/`countries-line` → `regions`/`regions-fill`/`regions-line`, and the border line is thinned to `0.75` (from `1.25`) so the subdivision reads within each country. +- **Ownership stays at country granularity.** A nation still owns whole countries, so every sub-region of a country shares that country's owning-nation color (the fill is "same colors as the country map, but subdivided"). The owner of a sub-region is resolved from the RegionMap via the feature's `properties.parent` (the parent country's ISO_A3) — never the sub-region id (`FRA-03`) nor the repacked top-level `feature.id`. Clicking a sub-region switches the dossier to its parent country's owning nation (unchanged behavior); the hover tooltip now also shows the sub-region name + its parent country; a small "Selected …" label appears in the dossier after a map click. +- **New shared helper `mapFeatureOwner(regions, feature)`** in `@shared/protocol` — the single source of truth for resolving a rendered map feature's owner via `properties.parent` → `RegionMap[parent]` (built on the existing `regionOwner` key lookup). The territory-change **flash** now expands a changed country to **all** of its sub-regions (via the new `featuresByParents`), so a whole-country transfer still pulses correctly at the finer resolution. +- **No ownership-model or LLM change** — `buildRegionMap`/`RegionMap` remain ISO_A3-keyed and the engine still references whole countries; this PR only changes what the map draws. Sub-region-level ownership (individual regions changing hands) is PR 3. +- **Tests** — new `mapFeatureOwner` unit tests and an updated #6 regression guard pinning the sub-region feature shape (numeric `id`, `regionId` = sub-region code, `parent` = ISO_A3) and proving resolution via `parent` while the sub-region id and repacked id do not resolve (55 unit tests). The e2e suite needed no changes: it asserts on the legend (derived from the ISO_A3 RegionMap) and screen structure, not on map geometry. +- Part of #16. + ## v0.11.0 — 2026-10-12 00:31 - **Sub-national region dataset + generator (#16, foundation only — no app behavior change).** A new dependency-free `scripts/generate-regions.mjs` (run via `npm run generate:regions`) splits each of the 177 countries in `public/countries.geo.json` into adaptive ~100–200 km sub-national regions on a regular lat/lon grid clipped to the country (Sutherland–Hodgman), and writes the committed `public/regions.geo.json` (**2,597 regions**, ~755 KB). Each region is id'd `-` (nn = 1..k in row-major order, capped at 36 per country) with `properties = { name, parent }`, so downstream work can map any region back to its parent nation. Antimeridian countries are handled three ways: small island groups (Fiji) stay a single whole-country region; large ones (Russia) are tiled in shifted [0,360) longitude space with output rings split at ±180°; and the full-360° Antarctic wrap ring becomes two hemispheric wedges. The output is fully deterministic — the same input yields a byte-identical file (verified by sha256). - **Validation test** — new self-contained `scripts/regions.test.ts` (picked up by `npm test`) reads the two GeoJSON files once each and asserts: the file is a well-formed FeatureCollection; every region is a valid feature whose id matches `^[A-Z]{3}-\d+$` and whose `parent` is a real country id; every country has 1–36 regions (≥2 for any country larger than 50,000 km²); every region has a sane area (≥ 200 km², with a microstate exemption, and < 5,000,000 km²); and each country's regions sum to 45–108% of its own area (the measured range is ~[64%, 105%] — Antarctica's wedges under-fill, Norway's high-latitude fjord coast over-fills by 0.1%). No change to the running app, the LLM contract, or the map — this is purely the dataset + tooling that later PRs for #16 build on. diff --git a/package.json b/package.json index a570fe7..6d1b868 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.11.0", + "version": "0.12.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/map.test.ts b/server/map.test.ts index 72c713c..31338ae 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildRegionMap } from './map'; -import { nationColor, regionOwner, type RegionMap } from '../shared/protocol'; +import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol'; import type { GameState } from '../shared/types'; const state: GameState = { @@ -74,40 +74,62 @@ describe('regionOwner', () => { }); /** - * #6 regression guard. The click/hover handlers resolve the owning nation from a - * map feature. MapLibre repacks a GeoJSON source's top-level `id` (the ISO_A3 - * string) into a *numeric index* when it stores the source, so a feature the map - * hands back at interaction time has a number for `id` and the ISO_A3 only lives - * in `properties.regionId`. This test pins the exact data shape and proves the - * fix: resolution via `properties.regionId` works, while the pre-fix path that - * read `feature.id` does not. + * #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. */ -describe('#6 — owner must be resolved from properties.regionId, not the repacked feature.id', () => { - // The shape MapLibre returns for a source feature after setData(): `id` is a - // number (here 0, as observed in the live map), and the ISO_A3 is preserved - // only in properties (the client stamps it as `regionId`). +describe('#6 — sub-region owner must be resolved from properties.parent, 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). const repackedFeature = { id: 0, - properties: { name: 'DE', regionId: 'DEU' }, + properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' }, }; - it('resolves the owner from properties.regionId (the fix)', () => { - const code = typeof repackedFeature.properties.regionId === 'string' ? repackedFeature.properties.regionId : ''; - expect(regionOwner(regions, code)).toEqual({ + it('resolves the owner from properties.parent (the fix)', () => { + expect(mapFeatureOwner(regions, repackedFeature)).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de', }); }); - it('the old path (feature.id as a string key) does NOT resolve — the bug', () => { - // String(0) === '0' is not a valid ISO_A3 key, so the pre-fix lookup failed - // and the click popup reported "No owner" even for owned regions. - expect(regions[String(repackedFeature.id)]).toBeUndefined(); + 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', () => { + // 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(); }); }); +describe('mapFeatureOwner', () => { + it('returns undefined for a missing feature, missing parent, or non-string parent', () => { + 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(); + }); + + it('returns undefined for a parent country nobody owns', () => { + expect(mapFeatureOwner(regions, { properties: { parent: 'FRA' } })).toBeUndefined(); + expect(mapFeatureOwner(regions, { properties: { parent: 'USA' } })).toBeUndefined(); + }); +}); + describe('nationColor', () => { it('is deterministic per id and distinct across different ids', () => { expect(nationColor('uk')).toBe(nationColor('uk')); diff --git a/shared/protocol.ts b/shared/protocol.ts index 813ab44..c9efdc8 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -57,20 +57,41 @@ export interface RegionOwner { export type RegionMap = Record; /** - * Look up the owner record for a map region from the authoritative RegionMap by - * its ISO_A3 code. Returns `undefined` for an empty/missing code or an unowned - * region. Pure + shared: the client resolves a clicked or hovered map feature by - * first reading the feature's stable ISO_A3 code (stored in `properties.regionId` - * by the client when it repacks the source — see WorldMap) and then calling this. + * 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 code must NOT be taken from the feature's top-level `id`: MapLibre repacks - * a GeoJSON source's `id` (an ISO_A3 string) into a numeric index when it stores - * the source, so at click/hover time `feature.id` is a number, never the ISO_A3 - * — resolving from it is exactly the "no owner" bug (#6). + * 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. */ -export function regionOwner(regions: RegionMap, isoA3: string | null | undefined): RegionOwner | undefined { - if (!isoA3) return undefined; - return regions[isoA3]; +export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined { + if (!key) return undefined; + return regions[key]; +} + +/** + * 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. + * + * Returns `undefined` for a missing feature/parent or an unowned country. + */ +export function mapFeatureOwner( + regions: RegionMap, + feature: { properties?: { parent?: unknown } } | null | undefined, +): RegionOwner | undefined { + const parent = feature?.properties?.parent; + return regionOwner(regions, typeof parent === 'string' ? parent : ''); } /** Server -> client Server-Sent Events payload (see server/index.ts /sse). */ diff --git a/src/App.tsx b/src/App.tsx index 705172f..306b4a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -589,6 +589,17 @@ function GameScreen({ }: GameScreenProps) { // Which nation's dossier to show. Defaults to the player; remounts per game. const [viewingId, setViewingId] = useState(game.playerNationId); + // When the dossier target was set by a map click, remember which sub-region + // (its own name + parent country) was clicked, for a small label in the + // dossier. Cleared when the user picks a nation via the manual Inspect select. + const [selectedRegion, setSelectedRegion] = useState<{ parent?: string; name?: string } | null>(null); + const focusNation = useCallback( + (id: string, info?: { parent?: string; name?: string }) => { + setViewingId(id); + setSelectedRegion(info ?? null); + }, + [], + ); const player = game.nations[game.playerNationId]; const viewed = game.nations[viewingId] ?? player; const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name)); @@ -641,7 +652,7 @@ function GameScreen({ playerNationId={game.playerNationId} nations={game.nations} presetId={game.presetId} - onFocusNation={setViewingId} + onFocusNation={focusNation} /> {/* Pinned top bar: brand, turn + date, player nation, live status, Focus map. @@ -747,7 +758,14 @@ function GameScreen({
Inspect
- { + // A manual pick supersedes a map-click selection: clear the label. + setViewingId(id); + setSelectedRegion(null); + }} + > @@ -759,6 +777,12 @@ function GameScreen({ ))} + {selectedRegion && ( +
+ Selected: {selectedRegion.name ?? selectedRegion.parent ?? 'region'} + {selectedRegion.parent ? ` (${selectedRegion.parent})` : ''} +
+ )} diff --git a/src/components/WorldMap.tsx b/src/components/WorldMap.tsx index 2cabae4..1fc350b 100644 --- a/src/components/WorldMap.tsx +++ b/src/components/WorldMap.tsx @@ -8,11 +8,14 @@ import 'maplibre-gl/dist/maplibre-gl.css'; // Point it at a Vite-emitted copy of the worker module so it loads in prod. import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url'; maplibregl.setWorkerUrl(workerUrl); -import { regionOwner, type RegionMap, type RegionOwner } from '@shared/protocol'; +import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol'; import type { Nation } from '@shared/types'; import { cn } from '@/lib/utils'; -// Minimal GeoJSON types (avoids a hard @types/geojson dependency). +// 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 +// `properties = { name, parent }` where `parent` is the parent country's ISO_A3. interface GeoFeature { type: 'Feature'; id?: string | number; @@ -27,7 +30,7 @@ type GeoSource = maplibregl.GeoJSONSource; // Inline basemap style — a visible dark-navy background plus a subtle // OpenFreeMap shaded-relief raster so the ocean is never pure black and terrain -// is faintly visible. The country choropleth (added in `ensureLayers`) is drawn +// is faintly visible. The regional choropleth (added in `ensureLayers`) is drawn // on top and is the real star. // // Why inline instead of a remote style URL: the OFM "dark" style's vector tiles @@ -69,11 +72,11 @@ const INLINE_STYLE: maplibregl.StyleSpecification = { ], }; -const COUNTRIES_SOURCE = 'countries'; +const REGIONS_SOURCE = 'regions'; const HOVER_SOURCE = 'hover'; const FLASH_SOURCE = 'flash'; -const FILL_LAYER = 'countries-fill'; -const LINE_LAYER = 'countries-line'; +const FILL_LAYER = 'regions-fill'; +const LINE_LAYER = 'regions-line'; const PLAYER_LINE = 'player-line'; const FLASH_LINE = 'flash-line'; const HOVER_FILL = 'hover-fill'; @@ -97,29 +100,39 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu } /** - * Stamp the current ownership + colors onto a clone of the base geo. Each region - * carries its owner's stable fill color, a subtle stroke, and a flag for the - * player's own regions (rendered as a brighter, thicker border by a dedicated - * layer). Unknown owners fall back to a faint neutral fill — never an error. + * 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: * - * Crucially, the base GeoJSON feature's top-level `id` (the ISO_A3 code) is - * stamped into `properties.regionId`, and the owner's id into `properties.ownerId`. - * MapLibre repacks a GeoJSON source's top-level `id` into a numeric index when it - * stores it, so a later click/hover event exposes that number on - * `e.features[i].id` — the top-level `id` is *not* a reliable lookup key at - * interaction time (the root cause of #6). `properties`, however, survive into the - * rendered features, so the interaction handlers resolve the owning nation from - * `properties.regionId` via the RegionMap (see `regionOwner`). + * - `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); + * - `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`. */ 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 base GeoJSON `id` is the ISO_A3 code; keep it in a property that - // survives into the features MapLibre hands back on click/hover. - const code = f.id == null ? '' : String(f.id); - f.properties.regionId = code; - const r = code ? regions[code] : undefined; + // The sub-region's own id (e.g. `FRA-03`) — stamped into a property that + // survives into the rendered features (the top-level `id` gets repacked). + const 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; if (r) { f.properties.fill = r.color; f.properties.owner = r.owner; @@ -137,10 +150,18 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId return out; } -/** Build a FeatureCollection of just the features whose id is in `codes` (cloned). */ -function featuresByCodes(geo: FeatureCollection, codes: Iterable): FeatureCollection { - const set = new Set(codes); - const features = geo.features.filter((f) => f.id != null && set.has(String(f.id))); +/** + * 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. + */ +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); + }); return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) }; } @@ -152,14 +173,20 @@ function statRow(label: string, value?: number): string { return `
${label}${Math.round(value)}
`; } -/** Compact hover tooltip: country, its owner (color-chipped), and 1–2 key stats. */ -function hoverHtml(countryName: string, region?: RegionOwner, nation?: Nation): string { +/** + * Compact hover tooltip: the sub-region's name, its parent country (ISO_A3), its + * owner (color-chipped), and 1–2 key stats. When the parent country has no + * owning nation, a minimal "No owner" card is shown instead. + */ +function hoverHtml(subRegionName: string, parentCode: string, region?: RegionOwner, nation?: Nation): string { if (!region || !nation) { - return `
${esc(countryName || 'Unclaimed')}
No owner
`; + return `
${esc(subRegionName || parentCode || 'Unclaimed')}
No owner
`; } const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join(''); + const sub = parentCode ? `
${esc(parentCode)}
` : ''; return `
-
${esc(countryName || region.owner)}
+
${esc(subRegionName || region.owner)}
+ ${sub}
${esc(region.owner)}
${stats}
`; @@ -174,27 +201,38 @@ interface Props { presetId: string; className?: string; /** - * Called with a region's owning-nation id when that region is clicked, so the - * dossier switches to that nation. Not called for unowned regions (the dossier - * keeps its current target). See the component doc for the full behavior. + * 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. */ - onFocusNation?: (id: string) => void; + onFocusNation?: (id: string, info?: { parent?: string; name?: string }) => void; } /** - * World basemap (MapLibre GL) + a country choropleth colored by the owning - * nation. 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 (the choropleth is the star), thin borders, a brighter/thicker - * border for the player, a hover tooltip (owner + key stats), a **click that - * switches the dossier to the region's owning nation** (no popup — see #7), a - * transient flash on territory changes, and a per-scenario initial camera. + * 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 + parent country + 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. * - * Ownership for a hovered/clicked region is resolved from the authoritative - * RegionMap by the feature's stable ISO_A3 code in `properties.regionId` - * (see `regionOwner` in @shared/protocol) — never from the feature's top-level - * `id`, which MapLibre repacks into a numeric index at interaction time (#6). - * Unowned regions (no RegionMap entry) simply do not change the dossier. + * Ownership for a hovered/clicked sub-region is resolved from the authoritative + * RegionMap by its **parent country's** ISO_A3 code (`properties.parent`, 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. * * Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag) * — no floating control and no external API. @@ -255,9 +293,9 @@ export default function WorldMap({ const loadGeo = (): Promise => baseGeoRef.current ? Promise.resolve(baseGeoRef.current) - : fetch('/countries.geo.json') + : fetch('/regions.geo.json') .then((r) => { - if (!r.ok) throw new Error(`countries.geo.json → HTTP ${r.status}`); + if (!r.ok) throw new Error(`regions.geo.json → HTTP ${r.status}`); return r.json(); }) .then((g: FeatureCollection) => { @@ -268,12 +306,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. + // 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. function animateFlash(codes: string[]) { const geo = baseGeoRef.current; const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined; if (!geo || !src || cancelled) return; - src.setData(featuresByCodes(geo, codes)); + src.setData(featuresByParents(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. @@ -306,33 +346,34 @@ export default function WorldMap({ } flashApiRef.current = animateFlash; - // The region a hovered/clicked feature belongs to is identified by its stable - // ISO_A3 code, read from `properties.regionId` (stamped by `applyRegions`). - // We must NOT use the feature's top-level `id`: MapLibre repacks a GeoJSON - // source's `id` into a numeric index when it stores the source, so the value - // exposed on `e.features[i].id` at interaction time is a number, never the - // ISO_A3 — resolving the owner from it is the "no owner" bug (#6). + // 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). const onHover = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; - const p = f.properties as { name?: string; regionId?: string }; - const code = typeof p?.regionId === 'string' ? p.regionId : ''; - if (code && code !== lastHoverId) { - lastHoverId = code; + const p = f.properties as { name?: string; regionId?: string; parent?: string }; + const subId = typeof p?.regionId === 'string' ? p.regionId : ''; + const parent = typeof p?.parent === 'string' ? p.parent : ''; + if (subId && subId !== lastHoverId) { + lastHoverId = subId; const geo = baseGeoRef.current; - // The base GeoJSON feature's top-level `id` IS the ISO_A3, so match on - // that to recover the feature for the hover-highlight outline (the - // feature MapLibre hands us has a repacked numeric `id` we can't use). - const feat = geo?.features.find((x) => x.id != null && String(x.id) === code); + // The base GeoJSON feature's top-level `id` IS the sub-region id, so + // match on it to recover the exact feature for the hover-highlight + // outline (the feature MapLibre hands us has a repacked numeric `id`). + const feat = geo?.features.find((x) => x.id != null && String(x.id) === subId); (map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData( feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(), ); } - const region = regionOwner(regionsRef.current, code); + const region = mapFeatureOwner(regionsRef.current, f); const nation = region ? nationsRef.current[region.ownerId] : undefined; hoverPopup .setLngLat(e.lngLat) - .setHTML(hoverHtml(p?.name ?? code, region, nation)) + .setHTML(hoverHtml(p?.name ?? parent ?? subId, parent, region, nation)) .addTo(map); map.getCanvas().style.cursor = 'pointer'; }; @@ -345,17 +386,24 @@ export default function WorldMap({ }; // #7: a click does NOT open a popup — it switches the dossier to the region's - // owning nation. The owner is resolved from the stable ISO_A3 in - // `properties.regionId` via the RegionMap (never the repacked top-level - // `f.id`, #6). A region with no owner in the RegionMap (unowned territory) - // leaves the current dossier target unchanged — never a broken dossier. + // 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. const onClick = (e: maplibregl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; - const p = f.properties as { regionId?: string }; - const code = typeof p?.regionId === 'string' ? p.regionId : ''; - const region = regionOwner(regionsRef.current, code); - if (region?.ownerId) onFocusNationRef.current?.(region.ownerId); + 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, + }; + onFocusNationRef.current?.(region.ownerId, info); }; // Idempotent: (re)creates the sources + layers after any style (re)load and @@ -363,16 +411,16 @@ export default function WorldMap({ // automatically, so no setWorkerUrl is needed.) // // The three choropleth sources are created up front (empty until the base - // geo arrives) so the layer structure always exists; `paintCountries` then - // stamps ownership + colors onto the countries source. This makes it immune + // geo arrives) so the layer structure always exists; `paintRegions` then + // stamps ownership + colors onto the regions source. This makes it immune // to load-order races: if `ensureLayers` runs before the geo fetch // resolves, the data is applied the moment the fetch lands; if the geo // resolves first, `ensureLayers` uses it. No source is ever left stuck on // `emptyFC()` — `ensureLayers` is retried on every `styledata`, and the // regions effect re-paints whenever ownership changes. const ensureSources = () => { - if (!map.getSource(COUNTRIES_SOURCE)) { - map.addSource(COUNTRIES_SOURCE, { type: 'geojson', data: emptyFC() }); + if (!map.getSource(REGIONS_SOURCE)) { + map.addSource(REGIONS_SOURCE, { type: 'geojson', data: emptyFC() }); } if (!map.getSource(HOVER_SOURCE)) { map.addSource(HOVER_SOURCE, { type: 'geojson', data: emptyFC() }); @@ -382,10 +430,10 @@ export default function WorldMap({ } }; - // Stamp ownership + colors onto the countries source (no-op until geo loads). - const paintCountries = () => { + // Stamp ownership + colors onto the regions source (no-op until geo loads). + const paintRegions = () => { const geo = baseGeoRef.current; - const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined; + const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; if (!geo || !src) return; src.setData(applyRegions(geo, regionsRef.current, playerRef.current)); }; @@ -394,32 +442,39 @@ export default function WorldMap({ if (cancelled) return; ensureSources(); if (baseGeoRef.current) { - paintCountries(); + paintRegions(); } else { try { await loadGeo(); } catch (err) { - console.error('failed to load countries geojson', err); + console.error('failed to load regions geojson', err); } if (cancelled) return; - paintCountries(); + 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. map.addLayer({ id: FILL_LAYER, type: 'fill', - source: COUNTRIES_SOURCE, + source: REGIONS_SOURCE, paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 1.0 }, }); } if (!map.getLayer(LINE_LAYER)) { + // Thin internal borders: every sub-region outline at a light weight so + // the subdivision reads within each country's uniform fill. The per- + // feature `stroke` alpha keeps owned borders a touch brighter than + // unowned ones (the v0.10 treatment, thinned for the finer grid). map.addLayer({ id: LINE_LAYER, type: 'line', - source: COUNTRIES_SOURCE, - paint: { 'line-color': ['get', 'stroke'], 'line-width': 1.25, 'line-opacity': 0.95 }, + source: REGIONS_SOURCE, + paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.75, 'line-opacity': 0.95 }, }); } // NOTE: layers are added in bottom→top order (each appends on top). The @@ -430,7 +485,7 @@ export default function WorldMap({ map.addLayer({ id: PLAYER_LINE, type: 'line', - source: COUNTRIES_SOURCE, + source: REGIONS_SOURCE, filter: ['==', ['get', 'isPlayer'], 1], paint: { 'line-color': '#f59e0b', 'line-width': 2.2, 'line-opacity': 0.95 }, }); @@ -482,8 +537,10 @@ export default function WorldMap({ }; }, []); - // Re-color the choropleth whenever ownership changes, and flash any regions - // whose owner changed since the last update (transient, self-cleaning). + // 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. useEffect(() => { const map = mapRef.current; const geo = baseGeoRef.current; @@ -494,7 +551,7 @@ export default function WorldMap({ if (Object.keys(cur).length > 0) primedRef.current = true; if (map && geo) { - const src = map.getSource(COUNTRIES_SOURCE) as GeoSource | undefined; + const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined; if (src) void src.setData(applyRegions(geo, cur, playerNationId)); } -- 2.54.0