Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
327fffb789 | ||
|
|
a129c2ab81 | ||
|
|
aa1bd3c5aa | ||
|
|
f6ad554823 | ||
|
|
867b2eb0a1 | ||
|
|
8fb8984d07 | ||
|
|
f593af5ba5 | ||
|
|
c63f6acca1 | ||
|
|
c0c620a54c | ||
|
|
7eaed42d51 | ||
|
|
14c5d4881d | ||
|
|
458e9be419 | ||
|
|
f72a59bf5e | ||
|
|
6ebe21591e | ||
|
|
9f230606c4 | ||
|
|
e9fa267fd0 | ||
|
|
edaa8f55d1 | ||
|
|
6c52e10898 | ||
|
|
764f24fc55 | ||
|
|
cb426273a9 | ||
|
|
0b999f1448 | ||
|
|
5abbebc58f | ||
|
|
f53f9072f4 | ||
|
|
6f14a4e4c0 | ||
|
|
6949739956 | ||
|
|
cf7d3cb122 | ||
|
|
99ee93f6da | ||
|
|
b9ac79c57f | ||
|
|
1c64aa1da4 | ||
|
|
29927bae34 | ||
|
|
325bdc5255 | ||
|
|
2cfa2f78f9 | ||
|
|
2b84c34f41 |
@@ -28,12 +28,23 @@ renders territorial ownership; the whole thing persists to SQLite and streams ov
|
|||||||
- Backend: Node 22 + Express — one process serving the built client, `/api` (REST), and `/sse`
|
- 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)
|
- Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON)
|
||||||
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
|
- Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national**
|
||||||
choropleth from `public/regions.geo.json` (2,597 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
|
choropleth from `public/regions.geo.json` (1,800 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
|
||||||
`properties.parent` = the country's ISO_A3). Ownership and the LLM stay at **country**
|
`properties.parent` = the country's ISO_A3). Ownership is at **region-key** granularity
|
||||||
granularity, so a sub-region inherits its parent country's owner color (see #16).
|
(v0.19, #16 P1): every sub-region is addressed by a stable key from
|
||||||
|
`public/region-keys.json`, `Nation.territory` holds those keys, and each sub-region is
|
||||||
|
colored by its own owner. Since v0.21 (#16 P2) the LLM moves *individual* sub-regions
|
||||||
|
across shared land borders (frontier-biased transfers, verified with the `getNeighbours`
|
||||||
|
function-calling tool) instead of whole countries. Since v0.25 (#31) the old RU-ARK
|
||||||
|
mega-region is split into the mainland (`RU-ARK`, key unchanged) plus the New Earth
|
||||||
|
(`RU-ARK-NE`) and Severnaya Zemlya (`RU-ARK-SZ`) island groups; since v0.26 (#41) the
|
||||||
|
mainland's 28-part Arctic-coast band is further re-partitioned into Pechora (`RU-PEO`)
|
||||||
|
and Yamal (`RU-YAM`) (the 5-part lat < 68 core stays `RU-ARK`/`arkhangel-sk-lhbk`).
|
||||||
- The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an
|
- 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
|
**injectable transport** (mirrors `mesh-bot/src/llm`), with two entry points: plain
|
||||||
(`LLM_STUB=1`) is used by all tests/e2e.
|
`chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool
|
||||||
|
rounds, then one forced no-tools request) sharing the same retry/backoff/
|
||||||
|
`reasoning_effort` machinery (see the LLM config note below). A deterministic **stub**
|
||||||
|
transport (`LLM_STUB=1`) is used by all tests/e2e.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -45,22 +56,35 @@ history/
|
|||||||
├── eslint.config.js # flat config (ESLint 9)
|
├── eslint.config.js # flat config (ESLint 9)
|
||||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
||||||
├── vitest.config.ts # unit tests (server/**/*.test.ts)
|
├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 189 tests)
|
||||||
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||||
├── index.html
|
├── index.html
|
||||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
|
│ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator)
|
||||||
│ └── regions.geo.json # 2,597 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
│ ├── regions.geo.json # 1,800 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
||||||
|
│ ├── region-keys.json # feature id -> region key, 1,800 entries (the ownership/LLM address space, #16 P1)
|
||||||
|
│ ├── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
|
||||||
|
│ └── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44)
|
||||||
|
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
||||||
|
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
||||||
|
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
|
||||||
|
│ │ # → build-region-adjacency → build-region-centroids
|
||||||
|
│ └── *.test.ts # turf-free vitest tests pinning the dataset + the manifests
|
||||||
├── shared/ # NO runtime deps — the single source of truth
|
├── shared/ # NO runtime deps — the single source of truth
|
||||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||||
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers
|
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
|
||||||
|
│ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
|
||||||
|
│ # Jump + NationOp confirm/reject request types + pure helpers
|
||||||
├── server/
|
├── server/
|
||||||
│ ├── index.ts # Express: /api/* (action|diplomacy|queue/remove|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
|
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
|
||||||
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / removeRoundItem / advisor
|
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
|
||||||
│ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config
|
│ │ # PORT/LLM_*/DATA_DIR
|
||||||
│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER
|
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
|
||||||
│ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color)
|
│ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
|
||||||
|
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
|
||||||
|
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
|
||||||
|
│ ├── map.ts # GameState → RegionMap (region key → owner + stable color)
|
||||||
│ ├── presets.ts # load preset JSON files from disk
|
│ ├── presets.ts # load preset JSON files from disk
|
||||||
│ ├── db.ts # SQLite open/save/load
|
│ ├── db.ts # SQLite open/save/load
|
||||||
│ └── *.test.ts # unit tests (vitest)
|
│ └── *.test.ts # unit tests (vitest)
|
||||||
@@ -77,7 +101,8 @@ history/
|
|||||||
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
||||||
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
|
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
|
||||||
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
||||||
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
|
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + "Nation proposals" trigger (v0.23 #16 P4a)
|
||||||
|
├── NationOpDialog.tsx # review LLM-proposed nation ops (rename/split/merge) — confirm (editable rename/merge name, split successor radio) or reject (v0.24 #16 P4b)
|
||||||
├── EventFeed.tsx # scrolling event log (newest first)
|
├── EventFeed.tsx # scrolling event log (newest first)
|
||||||
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
|
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
|
||||||
├── StatBar.tsx
|
├── StatBar.tsx
|
||||||
@@ -91,10 +116,13 @@ history/
|
|||||||
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
||||||
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
|
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
|
||||||
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
|
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
|
||||||
| `npm test` | vitest unit tests in `server/` (39 tests) |
|
| `npm test` | vitest unit tests (`server/` + `scripts/`; 189 tests) |
|
||||||
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
||||||
| `npm run lint` | ESLint flat config |
|
| `npm run lint` | ESLint flat config |
|
||||||
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
||||||
|
| `npm run split:regions` | (re)apply the #31 Arkhangel'sk split (`RU-ARK` → mainland + `RU-ARK-NE` + `RU-ARK-SZ`) — idempotent no-op once applied |
|
||||||
|
| `npm run split:regions:band` | (re)apply the #41 Arctic-coast-band split (`RU-ARK` mainland's 28-part lat ≥ 68 band → `RU-PEO` + `RU-YAM`) — idempotent no-op once applied |
|
||||||
|
| `npm run build:region-keys` / `build:region-adjacency` / `build:region-centroids` | (re)generate the `public/region-keys.json` / `region-adjacency.json` / `region-centroids.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
||||||
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
|
||||||
|
|
||||||
**Local verification (before every PR):** `npm run typecheck` + `npm run lint` + `npm test` (unit) + `npm run build`. The full **e2e** suite (`npm run e2e`) is verified by **CI on every PR** (the Playwright config boots its own dev webServer with `LLM_STUB=1`) — locally you only need the four checks above; e2e is the CI source of truth.
|
**Local verification (before every PR):** `npm run typecheck` + `npm run lint` + `npm test` (unit) + `npm run build`. The full **e2e** suite (`npm run e2e`) is verified by **CI on every PR** (the Playwright config boots its own dev webServer with `LLM_STUB=1`) — locally you only need the four checks above; e2e is the CI source of truth.
|
||||||
@@ -143,29 +171,152 @@ history/
|
|||||||
(model down) surfaces as an HTTP 502 to the client (shown as a toast).
|
(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
|
- **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.
|
server's `RegionMap` and any client coloring so the choropleth always agrees.
|
||||||
- **Map click → dossier target (owner resolved from the RegionMap, via the parent country).**
|
- **Region-level territory (v0.19/v0.21, #16 P1+P2).** Since v0.19 ownership lives at sub-region
|
||||||
|
granularity: `Nation.territory` holds **region keys** (`<name-slug>-<4char>`, e.g. `moselle-rygy`;
|
||||||
|
the manifest is `public/region-keys.json`), `server/regions.ts` expands each preset's country list
|
||||||
|
to all its sub-regions' keys at game creation, and `buildRegionMap` keys the choropleth `RegionMap`
|
||||||
|
by region key. Since v0.21 transfers are **frontier-biased**: `applyTurnResult` is snapshot-first
|
||||||
|
and enforces **sole ownership** (a key transfers only when exactly one nation claims it to gain,
|
||||||
|
it has exactly one current owner, and the owner is not the gainer — a double claim is rejected and
|
||||||
|
a lone `lose` is a no-op, so a key can never be orphaned or duplicated), and the LLM can call the
|
||||||
|
`getNeighbours` tool (`GET_NEIGHBOURS_TOOL` + `makeNeighboursExecutor` in `engine.ts`, data from
|
||||||
|
`public/region-adjacency.json` via `neighboursOf`) to verify land borders before proposing a
|
||||||
|
transfer; the user payload carries the player's `frontier` (the sorted other-nation keys bordering
|
||||||
|
the player's territory, via `playerFrontier`). The tool path degrades gracefully: without the
|
||||||
|
adjacency manifest (or with a client lacking `chatWithTools`), a turn runs the legacy plain
|
||||||
|
`chat()` path and the tool/frontier simply switch off. Since v0.22 (#16 P3) a key may also end
|
||||||
|
a turn **unowned** — "no man's land": `TurnResult.unowned` lists keys released this turn;
|
||||||
|
`applyTurnResult` applies releases FIRST (a key leaves its sole owner — only a key with exactly
|
||||||
|
one current owner can be released; a never-assigned or corrupt key is a no-op), then claims
|
||||||
|
against the post-release snapshot, so a `gain` on an ownerless key is a **re-claim** (granted to
|
||||||
|
the sole gainer and removed from the cumulative `GameState.unowned` list). The user payload
|
||||||
|
carries the current `GameState.unowned` as `unowned`, and the jump route's `regions` reference
|
||||||
|
table includes those keys so the model can name them when re-claiming. The map renders a
|
||||||
|
released key in the distinct visible grey (`NO_MANS_LAND_FILL` in `shared/protocol.ts`, with a
|
||||||
|
"No man's land" tooltip sub-line) while a preset-unassigned key keeps the faint look ("No owner")
|
||||||
|
— the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` /
|
||||||
|
`unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh
|
||||||
|
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted). Since v0.25
|
||||||
|
(#31) the 78-part RU-ARK mega-region is split into three features — mainland `RU-ARK` (its key
|
||||||
|
`arkhangel-sk-lhbk` is **unchanged**, so existing ownership is safe), `RU-ARK-NE` (New Earth /
|
||||||
|
Franz Josef Land, `new-earth-eikt`) and `RU-ARK-SZ` (Severnaya Zemlya,
|
||||||
|
`severnaya-zemlya-awqp`) — by the idempotent `scripts/split-arkhangel.mjs` stage (run between
|
||||||
|
`fill:regions` and `build:region-keys`, no-op once applied); the two island groups get their own
|
||||||
|
region keys and adjacency entries (New Earth is genuinely isolated → empty adjacency; Severnaya
|
||||||
|
Zemlya's islet rings touch the coarsened mainland ring, so it records one SZ↔mainland pair).
|
||||||
|
Since v0.26 (#41) the mainland's 28-part Arctic-coast band (centroid lat ≥ 68) is further
|
||||||
|
re-partitioned by the idempotent `scripts/split-arkhangel-band.mjs` stage (run between
|
||||||
|
`split:regions` and `build:region-keys`, no-op once applied) into Pechora (`RU-PEO`,
|
||||||
|
`pechora-qwhs`, 13 parts) and Yamal (`RU-YAM`, `yamal-gosw`, 15 parts) — a pure re-partition
|
||||||
|
(no geometry moved → the union is byte-identical) that keeps `RU-ARK`'s id/name/key
|
||||||
|
`arkhangel-sk-lhbk` stable and inserts the two new features immediately after `RU-ARK-SZ`; the
|
||||||
|
5-part lat < 68 core stays `RU-ARK` (a single ~305k km² polygon — splitting it needs turf
|
||||||
|
clipping, not a pure re-partition).
|
||||||
|
- **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a; v0.24, #16 P4b).**
|
||||||
|
`TurnResult.nationOps` (required in the schema) carries the LLM’s proposed **`NationOp`s** — a
|
||||||
|
discriminated union of `rename` / `split` / `merge`, all three of which are now applied on confirm
|
||||||
|
(P4b implemented split/merge). Each op names its target via `nationRef` — the LLM may use a
|
||||||
|
nation’s **id or name**; `resolveNationRef` resolves id-first, then a case-insensitive
|
||||||
|
unique-name match, and returns `null` for an unknown or ambiguous ref (such ops are dropped
|
||||||
|
defensively in `validateNationOps`, never thrown). A turn’s ops are appended to
|
||||||
|
`GameState.pendingNationOps`, which is **never cleared on a turn commit** — pending proposals
|
||||||
|
persist across turns until resolved (like the action queue). The player then decides per op via
|
||||||
|
`POST /api/games/:id/nation-op/confirm` (`{index, edits?{name?, playerPart?}}`) or
|
||||||
|
`.../nation-op/reject` (`{index}`): `confirmNationOp` applies the op to a clone — **rename** sets
|
||||||
|
`Nation.name` to the edited value or, failing that, the proposed `to` (id / territory / relations /
|
||||||
|
`playerNationId` stay valid, so a renamed player is re-named in place); **split** (the parent
|
||||||
|
must resolve and have ≥2 named parts, else a no-op) partitions the parent’s region-key territory
|
||||||
|
into new nations in parts order — each new nation gets a fresh id (`freshNationId`: first free of
|
||||||
|
`n1`, `n2`, …) and the baseline `NEW_NATION_STATS` (not a copy of the parent’s), plus a copy of
|
||||||
|
the parent’s relations; a key named by several parts goes to the first, and the parent’s
|
||||||
|
unclaimed keys go to part 0 (in territory order), so sole ownership holds and nothing is
|
||||||
|
orphaned; if the parent was the player’s nation, `playerNationId` moves to the part chosen by
|
||||||
|
`edits.playerPart` (clamped into range, default 0), which also receives `edits.name`; **merge**
|
||||||
|
(every ref must resolve, ≥2 DISTINCT inputs, and the survivor must be one of them, else a
|
||||||
|
no-op) folds the inputs into the survivor, which keeps its id and its own stats, gains the
|
||||||
|
union of the inputs’ territories (the survivor’s keys first) and a union of their relations
|
||||||
|
(the survivor’s own view of a third party wins, else the first input’s), is renamed
|
||||||
|
`edits.name` → `into` → its own name, while the other inputs are removed and their ids dropped
|
||||||
|
from every remaining nation’s relations maps; if the player led a removed input,
|
||||||
|
`playerNationId` moves to the survivor. An op that is invalid at confirm time (an ambiguous ref,
|
||||||
|
…) is a no-op on the world but is still removed — and `rejectNationOp` just drops it; both
|
||||||
|
splice by index and bump `version` once (an invalid index → `null` → HTTP 400). The response is
|
||||||
|
`{ game }` and the server broadcasts a version-bumped SSE `state`, so every tab’s
|
||||||
|
version-guarded `applyState` stays in sync. The client lists the pending ops in a “Nation
|
||||||
|
proposals” dialog (in the “Your move” panel): a rename and a merge show an editable name field
|
||||||
|
(the merge also highlights the survivor among the input chips); a split lists each part (name +
|
||||||
|
proposed region count) with a successor radio that appears only when the parent is the player’s
|
||||||
|
own nation (preselected part 0 — the choice is always valid). The system prompt documents all
|
||||||
|
three op shapes as a proposal queue (at most 1–2 per turn, each narrated in events). The stub
|
||||||
|
never proposes a nation op (`nationOps: []`), so this path is covered by unit tests only
|
||||||
|
(`server/engine.test.ts`).
|
||||||
|
- **Map markers (v0.27, #44) — LLM-placed named points, region-anchored, canvas-icon rendered.**
|
||||||
|
The LLM can place named **point markers** on the map each turn: eight `MarkerType`s
|
||||||
|
(`city, factory, army, port, capital, fleet, rocket, gathering`; a **capital** is at most one
|
||||||
|
per nation, a **gathering** a political/social mass event that can enable a non-military
|
||||||
|
takeover). `TurnResult.markers = { add: MarkerSpec[]; remove: string[] }` (both required,
|
||||||
|
default `[]`; `MarkerSpec = { type, name, regionKey }`). A marker's stable `id` is the FNV-1a
|
||||||
|
32-bit hash of `` `${type}\u0000${name}\u0000${regionKey}` `` rendered as four lowercase
|
||||||
|
base-26 characters (mirroring the region-key scheme) — so a **re-add is idempotent** and a
|
||||||
|
**remove is unambiguous** (by id). `GameState.markers: MapMarker[]` is required (`[]` at game
|
||||||
|
creation; a legacy SQLite row without the field loads as `[]` — additive, never wipes).
|
||||||
|
`applyTurnResult` handles markers **after** territory claims: (1) remove by id (absent id →
|
||||||
|
no-op); (2) **recompute every surviving marker's `ownerNationId` from the final post-claim
|
||||||
|
ownership** — a marker always follows its region (a captured region follows its new owner; a
|
||||||
|
region released to no man's land, or a key with 0 or 2+ owners, becomes `null`); (3) add — each
|
||||||
|
new spec is placed at its region's centroid (from `public/region-centroids.json`, injected via
|
||||||
|
the optional `centroidOf` param on `applyTurnResult`) plus a deterministic per-axis jitter
|
||||||
|
(`spread ≈ 0.1 × the largest part's smallest bbox dimension`, so multiple markers of one region
|
||||||
|
scatter instead of stacking), owned by the region's post-turn owner, stamped with the acting
|
||||||
|
turn. Adds are **dropped, never thrown**, when the region key is unknown or has no centroid,
|
||||||
|
when the id already exists, or beyond the hard cap `MAX_MARKERS = 400`. The system prompt
|
||||||
|
documents the field + types + placement rules; `buildUserPrompt` carries the current markers
|
||||||
|
(as `MarkerInfo[]`) so the model can reference their ids in `remove`; a marker add/remove makes
|
||||||
|
a turn "meaningful". **Rendering is canvas-generated per-type ICON images, NOT text labels**
|
||||||
|
(`src/components/WorldMap.tsx`): every external glyph/font endpoint is a dead 404 (OpenFreeMap's
|
||||||
|
`fonts/{stack}/{range}.pbf` and `demotiles.maplibre.org/font/…`) and the OFM dark basemap only
|
||||||
|
renders because it is **raster** (labels baked into the tiles) — there is no working vector
|
||||||
|
font, so markers are drawn as self-contained, offline, deterministic per-type icons
|
||||||
|
(`drawMarkerIcon`: distinct shape per type, filled with the type color, outlined for legibility),
|
||||||
|
registered as named `ImageData` images that survive `styledata`. The marker **name shows in the
|
||||||
|
hover tooltip** (not a persistent on-map label), and **clicking a marker focuses its owning
|
||||||
|
nation's dossier**. There are **no separate marker event handlers** — MapLibre's per-layer event
|
||||||
|
delegates fire independently (no `stopPropagation`), so the region hover/click handlers instead
|
||||||
|
`queryRenderedFeatures` the marker layer under the cursor and act on the marker when present.
|
||||||
|
The **stub is marker-free** (`markers: { add: [], remove: [] }`) — e2e (CI, `LLM_STUB=1`) is
|
||||||
|
unaffected and the marker path is unit-tested only (`server/engine.test.ts`,
|
||||||
|
`scripts/region-centroids.test.ts`, `server/regions.test.ts`, `server/db.test.ts`).
|
||||||
|
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
||||||
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
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
|
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||||||
which region was clicked for a small dossier label); the manual "Inspect" selector and a map click
|
clicked for a small dossier label); the manual "Inspect" selector and a map click both set the
|
||||||
both set the same target and stay in sync (a manual pick clears the label). Ownership is resolved
|
same target and stay in sync (a manual pick clears the label). Ownership is resolved from the
|
||||||
from the authoritative `RegionMap` (keyed by the **parent country's** ISO_A3) via the feature's
|
authoritative `RegionMap` (keyed by **region key**) via the feature's stamped
|
||||||
`properties.parent`, using the shared `mapFeatureOwner()` helper (`regionId` still holds the
|
`properties.regionKey`, using the shared `mapFeatureOwner()` helper (`shared/protocol.ts`) —
|
||||||
sub-region's own id, e.g. `FRA-03`). It must **never** be resolved from the feature's top-level
|
`WorldMap` stamps each rendered feature with its key from `region-keys.json` when it builds the
|
||||||
`id` (MapLibre repacks it into a numeric index — the "no owner" bug, #6) nor from the sub-region
|
choropleth source. It must **never** be resolved from the feature's top-level `id` (MapLibre
|
||||||
`id` (not a `RegionMap` key). Sub-regions of a country with no `RegionMap` entry (unowned) leave
|
repacks it into a numeric index — the "no owner" bug, #6) nor from the raw sub-region `id` (not a
|
||||||
the current dossier target unchanged — never a broken dossier. The hover path (a non-closeable
|
`RegionMap` key). A sub-region whose key has no owner leaves the current dossier target
|
||||||
tooltip showing the sub-region name + parent country + owner + key stats) uses the same resolution.
|
unchanged — never a broken dossier. The hover path (a non-closeable tooltip showing the region
|
||||||
**PR 2 of #16:** the map renders `regions.geo.json` (sub-national) but ownership/territory and the
|
name + owner chip + key stats) uses the same resolution. `properties.parent` is static
|
||||||
LLM stay at country level — a nation owns whole countries, so all its sub-regions share the country's
|
geographic-origin data — never use it for ownership or display.
|
||||||
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
|
- **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
|
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
|
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
|
||||||
nation's `military` is set to `(current + 5)` (clamped) once per committed turn (not per item),
|
nation's `military` is set to `(current + 5)` (clamped) once per committed turn (not per item),
|
||||||
the player is allied with the first other nation (by key order in the `nations` record), and the
|
the player is allied with the first other nation (by key order in the `nations` record), and the
|
||||||
date advances 2 days. Unit + e2e assertions rely on these exact values, so keep the stub
|
date advances 2 days. Since v0.21 the stub also makes **one contiguous conquest per turn**: the
|
||||||
deterministic.
|
player gains `frontier[0]` (the first of the sorted `frontier` list from the prompt payload —
|
||||||
|
stable) and that region's owner loses it; an empty frontier (no controllable border, e.g. the UK
|
||||||
|
in `wwii-1939`) transfers nothing. Since v0.22 (#16 P3) the stub **never releases** a region to
|
||||||
|
no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its
|
||||||
|
one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is
|
||||||
|
covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). Since v0.23 (#16 P4a)
|
||||||
|
the stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge;
|
||||||
|
still true since P4b implemented split/merge), so the confirm/reject path is covered by unit
|
||||||
|
tests only, mirroring the P3 approach; all e2e assertions are unaffected. The stub answers plain `content` (never tool calls), so it naturally
|
||||||
|
bypasses the tool loop in a single round. Unit + e2e assertions rely on these exact values, so
|
||||||
|
keep the stub deterministic.
|
||||||
- **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The
|
- **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The
|
||||||
top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers
|
top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers
|
||||||
on resize and always floats above the other windows). The **dossier / event-feed / action**
|
on resize and always floats above the other windows). The **dossier / event-feed / action**
|
||||||
@@ -208,6 +359,12 @@ history/
|
|||||||
> ignoring `reasoning`. `chat()` retries transient failures (timeout/abort, 5xx, network
|
> ignoring `reasoning`. `chat()` retries transient failures (timeout/abort, 5xx, network
|
||||||
> errors, null/empty `content`) up to 3 attempts with 2s→3s backoff and logs every call
|
> errors, null/empty `content`) up to 3 attempts with 2s→3s backoff and logs every call
|
||||||
> (`[llm] chat ok` / `[llm] chat failed`). No auth header is sent — the endpoint requires no key.
|
> (`[llm] chat ok` / `[llm] chat failed`). No auth header is sent — the endpoint requires no key.
|
||||||
|
> The same machinery backs `chatWithTools()` (the function-calling loop used by turns when the
|
||||||
|
> region-adjacency manifest is loaded): a `finish_reason: "tool_calls"` response is a *successful*
|
||||||
|
> round (never a truncation error), each requested tool call is run through the injected executor,
|
||||||
|
> the assistant tool-call message plus one `tool` result message per call are appended to the
|
||||||
|
> history, and the loop re-sends until a final `content` — max `DEFAULT_MAX_TOOL_ROUNDS = 6`
|
||||||
|
> tool rounds, after which one last *no-tools* request forces the answer.
|
||||||
| `PORT` | `4321` | single server port |
|
| `PORT` | `4321` | single server port |
|
||||||
| `DATA_DIR` | `./data` | where `history.db` lives |
|
| `DATA_DIR` | `./data` | where `history.db` lives |
|
||||||
| `CLIENT_DIST` | `./dist` | built client to serve (auto-found) |
|
| `CLIENT_DIST` | `./dist` | built client to serve (auto-found) |
|
||||||
@@ -247,6 +404,11 @@ history/
|
|||||||
caught.
|
caught.
|
||||||
- **`better-sqlite3`** is the only native module; the Docker build relies on its prebuilt binary
|
- **`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.
|
(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; the `public/` geo files
|
- The `dist/` client is committed to the build output only; the `public/` data files
|
||||||
(`regions.geo.json` — the choropleth — and `countries.geo.json`) are copied into `dist/` by Vite
|
(`regions.geo.json` — the choropleth — `region-keys.json` + `region-adjacency.json` — the
|
||||||
at build time, so the prod image serves them from `dist/` (no separate copy).
|
ownership/adjacency manifests — `region-centroids.json` — the marker anchors (#44) — and
|
||||||
|
`countries.geo.json`) are copied into `dist/` by Vite at
|
||||||
|
build time, so the prod image serves them from `dist/` (no separate copy). The server's region
|
||||||
|
loader (`server/regions.ts`) probes `public/` then `dist/` for the manifest pair, because the
|
||||||
|
Docker image keeps only `dist/` (the v0.20 fix — before it, region-level ownership silently
|
||||||
|
never activated in prod).
|
||||||
|
|||||||
+94
-9
@@ -2,15 +2,100 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
## v0.19.0 — 2026-10-13 22:02
|
## v0.27.0 — 2026-10-14 22:34
|
||||||
- **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.**
|
- **#44: the LLM can place named point markers on the map.** The model may now place up to a handful of named *point markers* per turn (a capital, city, factory complex, army deployment, port, fleet concentration, rocket launch site, or a political **gathering**), each anchored to a region key, persisted per game, and rendered on the choropleth as a MapLibre symbol layer.
|
||||||
- `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).
|
- **Types (`shared/types.ts` + `shared/protocol.ts`).** Eight `MarkerType`s — `city, factory, army, port, capital, fleet, rocket, gathering` — with a shared `MARKER_TYPE_INFO` map (display label + color + glyph). A `MapMarker = { id, type, name, regionKey, lat, lon, ownerNationId, placedTurn }`; `GameState.markers: MapMarker[]` is **required** (`[]` at game creation, and a legacy SQLite row without the field loads as `[]` — additive, never wipes existing markers). `TurnResult.markers = { add: MarkerSpec[]; remove: string[] }` (both required, default `[]`); `MarkerSpec = { type, name, regionKey }`.
|
||||||
- **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).**
|
- **Stable ids (`shared/protocol.ts`).** A marker's `id` is the FNV-1a 32-bit hash of `` `${type}\u0000${name}\u0000${regionKey}` `` rendered as four lowercase base-26 characters (mirroring the region-key scheme). This makes a **re-add idempotent** (the same type+name+region never creates a second marker) and a **remove unambiguous** (by id).
|
||||||
- **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`).
|
- **Centroid data (`public/region-centroids.json`, new).** ~1,800 entries keyed identically to `region-keys.json`, each `{ lat, lon, spread }`: the anchor is the **centroid of the region's largest-area part** (a single representative point, not an average across disjoint parts), and `spread` ≈ **0.1 × that part's smallest bbox dimension** (in degrees). A new idempotent pipeline stage `scripts/build-region-centroids.mjs` (`npm run build:region-centroids`) regenerates it deterministically (byte-identical re-run, sha256-pinned by the test). The engine uses the centroid as the marker's base position and the `spread` to jitter multiple markers of one region around it (a deterministic per-axis FNV-1a-derived offset, so markers scatter instead of stacking).
|
||||||
- **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).
|
- **Engine (`server/engine.ts`).** `applyTurnResult` processes markers **after** territory claims, in three steps: (1) remove by id (a listed-but-absent id is a no-op); (2) **recompute every surviving marker's `ownerNationId` from the final post-claim ownership** — a captured region follows its new owner, a region released to no man's land (or a key with 0 or 2+ owners) becomes `null` (a marker always follows its region); (3) add — each new spec is placed at its region's centroid (injected via a new optional `centroidOf` param) plus the per-axis jitter, owned by the region's post-turn owner, stamped with the acting turn. Adds are dropped (never thrown) when their region key is unknown / has no centroid, when the id already exists (idempotent re-add), or beyond the hard cap `MAX_MARKERS = 400` (later adds dropped). The system prompt documents the field, the eight types, and the placement rules; `buildUserPrompt` carries the current markers (as `MarkerInfo[]`) so the model can reference their stable ids in `remove`; `isMeaningful` treats a marker add/remove as a real world change.
|
||||||
- **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.
|
- **Client (`src/components/WorldMap.tsx`).** A second GeoJSON source + a symbol layer sits above the choropleth. **Deliberate deviation from the original "name as a text label" design:** every external glyph endpoint is a dead 404 (OpenFreeMap's `fonts/{stack}/{range}.pbf` and `demotiles.maplibre.org/font/…`), and the OFM dark basemap only renders at all because it is **raster** (labels baked into the PNG tiles) — there is no working vector font. So markers are drawn as **self-contained, offline, canvas-generated per-type icons** (`drawMarkerIcon`: a distinct shape per type — circle / rounded-square / triangle / diamond / star / center-dotted circles / up-arrow — filled with the type color, outlined for legibility on the dark basemap), registered as named `ImageData` images that survive `styledata`. The marker **name is shown in the hover tooltip** (region name + owner chip + marker name) instead of a persistent on-map label, and **clicking a marker focuses its owning nation's dossier** (a marker's owner is its region's owner). There are no separate marker event handlers: MapLibre's per-layer event delegates fire independently (no `stopPropagation`), so the region hover/click handlers instead `queryRenderedFeatures` the marker layer under the cursor and act on the marker when present.
|
||||||
- **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.
|
- **Stub (`server/stub.ts`) stays marker-free** — it returns `markers: { add: [], remove: [] }` — so the e2e suite (CI, `LLM_STUB=1`) is unaffected and fully deterministic; the full marker path is covered by unit tests only.
|
||||||
- Part of #16
|
- **Tests (168 → 189).** New `scripts/region-centroids.test.ts` (1,800 entries, key-set match, finite/world-bounds, each centroid inside its feature's bbox via turf-free coordinate iteration, spot-checked anchors, byte-identical re-run determinism); `server/regions.test.ts` (`hasCentroids`/`centroidOf` in dev + prod-`dist/` layouts, graceful degradation when absent); `server/db.test.ts` (legacy row → `markers: []`); and an `engine.test.ts` marker suite (valid add → id/position/owner/placedTurn, idempotent re-add, region-validity/centroid drops, the 400 cap, remove by id / no-op, owner-follows-region incl. release→null and re-claim, `buildUserPrompt`/system-prompt contract, `validateMarkerOps` raw cleaner, and the `runTurn` centroid wiring).
|
||||||
|
- **Verification.** Typecheck, lint, **189 unit tests**, and the production build all pass (the build copies `region-centroids.json` into `dist/` like the other manifests, so it works in the prod container layout).
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host — **no `down -v`** (markers are additive; legacy games load with `markers: []`).
|
||||||
|
|
||||||
|
## v0.26.0 — 2026-10-14 19:24
|
||||||
|
- **#41: the `RU-ARK` mainland's Arctic-coast band is re-coarsened into two new regions** — a pure re-partition of the existing polygon parts: no new geometry, no coordinate change (the feature union stays byte-identical). After #31 the mainland `RU-ARK` was a 33-part MultiPolygon; its 28-part band (parts whose centroid lat ≥ 68 — the Arctic-coast strip) is now split into:
|
||||||
|
- `RU-PEO` — Pechora (13 parts, ~37,678 km²) → new region key `pechora-qwhs`. The southwestern band (around the Pechora delta / Nenets border).
|
||||||
|
- `RU-YAM` — Yamal (15 parts, ~51,722 km²) → new region key `yamal-gosw`. The northeastern band (Yamal peninsula).
|
||||||
|
- The 5 remaining lat < 68 parts (the ~305,229 km² inland core) **stay `RU-ARK` and keep its id, name, and region key `arkhangel-sk-lhbk`** — ownership-safe, mirroring the #31 precedent. Splitting that core further requires geometry clipping (a turf-op), not a pure re-partition, and is left as a follow-up issue.
|
||||||
|
- **New pipeline stage** `scripts/split-arkhangel-band.mjs` (`npm run split:regions:band`), run between `split:regions` and `build:region-keys`: deterministic and **idempotent** (a second run is a no-op that writes nothing). It asserts the mainland is a 33-part MultiPolygon, selects the band as the 28 parts with centroid lat ≥ 68 (exactly 28, or it fails loudly), picks the two largest-area band parts as Voronoi anchors (the smaller-longitude one, `idx 1`, anchors the Pechora side), and assigns each band part to the nearer anchor by km-weighted centroid distance `hypot(Δlon·111.32·cos(midLat), Δlat·110.574)` (ties go west). The resulting partition is exactly PEO={1,4,5,9,11,12,13,14,16,17,18,20,22}, YAM={2,6,15,19,21,23,24,25,26,27,28,29,30,31,32}, core={0,3,7,8,10}. The two new features are inserted **immediately after `RU-ARK-SZ`** (stable file order: `RU-ARK` | `RU-ARK-NE` | `RU-ARK-SZ` | `RU-PEO` | `RU-YAM` | `RU-AST` | …).
|
||||||
|
- **Manifests regenerated** with the real scripts and remain deterministic/idempotent (byte-identical re-run): `public/region-keys.json` and `public/region-adjacency.json` are now **1,800 entries each** (was 1,798); adjacency pairs 4,471 → 4,473. The new pairs are `pechora-qwhs ↔ yamal-gosw`, `pechora-qwhs ↔ nenets-qyga`, and `yamal-gosw ↔ severnaya-zemlya-awqp`, while the mainland `arkhangel-sk-lhbk` **loses** its `severnaya-zemlya-awqp` pair (the islets now touch the Yamal band, not the inland core) and keeps its six real neighbours (`karelia-ohem`, `kirov-gfvm`, `komi-eoeh`, `murmansk-etas`, `nenets-qyga`, `vologda-ynvr`). `new-earth-eikt` stays isolated (empty adjacency).
|
||||||
|
- **Fixed-point dataset unchanged except feature count** (1,798 → 1,800): the part/ring/vertex totals (11,794 / 11,795 / 164,801) and the single hole (KZ-KZY) are identical before and after — only the feature grouping changes. The RU-ARK family total stays 78 parts (mainland 5 / NE 42 / SZ 3 / PEO 13 / YAM 15).
|
||||||
|
- **Tests (168, count unchanged — existing cases updated, none added).** `scripts/regions.test.ts` (dataset is now 1,800 features; the Arkhangel feature-pin test now covers all five family members), `scripts/region-keys.test.ts` (1,800 keys; pins the unchanged `arkhangel-sk-lhbk` plus the two new keys), `scripts/region-adjacency.test.ts` (1,800 entries; pins the new PEO/YAM/SZ adjacency and the mainland's loss of the SZ pair), `server/regions.test.ts` (1,800; the RUS country expansion is now **84** sub-regions including all five keys — was 82 after #31). No e2e assertion references the region counts, so the CI e2e suite is unaffected.
|
||||||
|
- **LLM/engine/stub unchanged.** At game creation RUS simply auto-owns the two extra band regions (82 → 84 sub-regions); the LLM's reference table and the player's frontier now include `pechora-qwhs` and `yamal-gosw`.
|
||||||
|
- Deployed to `https://history.dev.kkhost.pl` with the authorized **clean data wipe** (`docker compose down -v` → `up -d --build`; the alpha `history-data` volume was destroyed, resetting all saved games).
|
||||||
|
|
||||||
|
## v0.25.0 — 2026-10-14 17:53
|
||||||
|
- **#31: the `RU-ARK` mega-region is split into a mainland region plus two island groups** — a pure re-grouping of the existing polygon parts: no new geometry, no coordinate changes. The old single feature (Arkhangel'sk Oblast + the Arctic archipelagos, 78 parts, ~410,187 km²) becomes three features inserted immediately after `RU-ARK` (stable file order: `RU-ARK` | `RU-ARK-NE` | `RU-ARK-SZ` | `RU-AST`):
|
||||||
|
- `RU-ARK` — the mainland (33 parts, ~393,256 km²); it **keeps its existing id and region key `arkhangel-sk-lhbk`**, so existing `Nation.territory` lists and SQLite ownership are untouched (ownership-safe).
|
||||||
|
- `RU-ARK-NE` — New Earth / Franz Josef Land (42 parts, ~15,721 km²) → new region key `new-earth-eikt`. Genuinely isolated (≥100 km from any land), so its adjacency list is **empty**.
|
||||||
|
- `RU-ARK-SZ` — Severnaya Zemlya (3 parts, ~1,210 km²) → new region key `severnaya-zemlya-awqp`. Its islet rings share boundary vertices with the coarsened mainland ring (a contact that pre-existed inside the single pre-split feature — no coordinate change), so it records **one** adjacency pair (SZ ↔ `arkhangel-sk-lhbk`). This deviates from the original "both islands isolated" expectation and is recorded as-is by the manifest's "shares at least one point" definition.
|
||||||
|
- **New pipeline stage** `scripts/split-arkhangel.mjs` (`npm run split:regions`), run between `fill:regions` and `build:region-keys`: deterministic and **idempotent** (a second run is a no-op that writes nothing). It classifies each part by centroid (turf — allowed in `.mjs` only): main = lat < 76 (plus any 76–79.5° band parts west of 55°E — none exist), Severnaya Zemlya = 76 ≤ lat < 79.5 AND lon ≥ 55, New Earth = lat ≥ 79.5. It fails loudly on a mis-split, a missing feature, or a non-MultiPolygon geometry.
|
||||||
|
- **Manifests regenerated** with the real scripts and remain deterministic/idempotent (byte-identical re-run): `public/region-keys.json` and `public/region-adjacency.json` are now **1,798 entries each** (was 1,796); adjacency pairs 4,470 → 4,471 (the one new pair is the SZ ↔ mainland contact; the mainland keeps its 6 real neighbours).
|
||||||
|
- **Fixed-point dataset unchanged except feature count** (1,796 → 1,798): the part/ring/vertex totals (11,794 / 11,795 / 164,801) and the single hole (KZ-KZY) are identical before and after the split.
|
||||||
|
- **Tests (164 → 168).** `scripts/regions.test.ts` (dataset is now 1,798 features; a new test pins the three Arkhangel features and their part counts), `scripts/region-keys.test.ts` (1,798 keys; a new test pins the unchanged main key plus the two new keys), `scripts/region-adjacency.test.ts` (1,798 entries; a new island-adjacency test: New Earth empty, Severnaya Zemlya = [mainland], mainland's neighbour list extended by exactly the SZ pair), `server/regions.test.ts` (1,798; the RUS country expansion is now **82** sub-regions including all three keys — was 80). No e2e assertion references the old counts, so the CI e2e suite is unaffected.
|
||||||
|
- **LLM/engine/stub unchanged.** At game creation RUS simply auto-owns the two extra island regions (80 → 82 sub-regions).
|
||||||
|
- Deployed to `https://history.dev.kkhost.pl` with the authorized **clean data wipe** (`docker compose down -v` → `up -d --build`; the alpha `history-data` volume was destroyed, resetting all saved games).
|
||||||
|
|
||||||
|
## v0.24.0 — 2026-10-14 13:41
|
||||||
|
- **Part 4b of #16: nation splits and merges — "LLM proposes, player confirms" is now complete.** P4a let the LLM propose a *rename* (a safe, single-field change). Now the same confirm/reject flow actually applies the two destructive ops: a **split** (one nation dissolves into several) and a **merge** (several nations fold into one). This is the last piece of #16 — all three `NationOp` kinds are now applied on player confirm.
|
||||||
|
- **Split (`server/engine.ts`).** `applyNationOp` now handles `kind: "split"`: it resolves the parent `nationRef` and requires **≥2** named parts (fewer → a safe no-op, the op is still resolved and removed). It partitions the parent's **region-key** territory across the parts **in order** — each part gets the keys of its `territory` that the parent actually owns and that no earlier part claimed (the **first part to name a key wins**, so a key is never held twice); any of the parent's keys no part claimed go to **part 0** (in the parent's territory order), so nothing is orphaned. Each new nation gets a **fresh id** (`freshNationId`: first free of `n1`, `n2`, …, tracked in a running `taken` set so ids never collide within the op), the baseline `NEW_NATION_STATS` (a neutral 50/50/50/50 profile — *not* a copy of the parent's, since a small rump state is not the parent's power), a copy of the parent's relations, and the parent's `type`; the parent is then removed and its id dropped from every other nation's relations map (`dropRelationsTo`). If the **parent was the player's nation**, `playerNationId` moves to the chosen part (`edits.playerPart`, clamped into range, default 0) — that same part also receives an `edits.name` override.
|
||||||
|
- **Merge (`server/engine.ts`).** `kind: "merge"`: every input ref and the survivor ref must resolve, at least **two distinct** inputs are required, and the **survivor must be one of the inputs** — any failure is a safe no-op. The survivor keeps its **id** and its **own stats**; it gains the **union of all inputs' territories** (the survivor's keys first, then each other input's unclaimed keys in its own order) and a **union of the inputs' relations** (the survivor's own view of a third party wins, else the first input's); it is renamed to `edits.name` → `into` → its own name (in that precedence). The other inputs are removed and their ids dropped from every remaining nation's relations maps; if the **player led a removed input**, `playerNationId` moves to the survivor.
|
||||||
|
- **Confirm contract extended (`shared/protocol.ts`).** `ConfirmNationOpRequest.edits` is now `{ name?: string; playerPart?: number }` — `name` overrides a rename's target or a merge's merged name; `playerPart` picks which new part the player's nation becomes in a split (default 0). The `SplitOp` / `MergeOp` / `NationOp` doc comments and the `TurnResult.nationOps` doc now describe the *applied* semantics (no longer "a no-op until P4b").
|
||||||
|
- **Prompt (`server/engine.ts`).** The system prompt's JSON contract now shows all three `nationOps` shapes (rename / split / merge), and the HARD CONSTRAINTS gain one line per op explaining when to use it and the "≥2 parts" / "survivor must be an input" rules — so the model knows what it may propose and that each is a player-confirmed PROPOSAL.
|
||||||
|
- **Client (`src/components/NationOpDialog.tsx`).** The dialog now renders all three kinds with a Confirm button (no more "dismiss-only" split/merge rows): a **split** lists each part (name + the number of regions it was assigned) and, when the split's parent is the **player's own nation**, shows a **radio group** to pick which new part the player leads (preselected 0, so the choice is always valid); a **merge** shows the input nations as chips (the **survivor** highlighted) and an **editable name** for the merged state (defaulting to `into`). Rename keeps its editable-name behavior. The new `onConfirm(index, edits?)` signature threads `playerPart` through `ActionPanel` and `App.tsx` (`runConfirmNationOp`) to the API helper.
|
||||||
|
- **Defensive invariants.** All ops are **pure and never throw**: an unresolvable or ambiguous ref, too few parts, or a survivor that isn't an input makes the op a no-op on the world (the proposal is still resolved and removed on confirm, mirroring P4a's ambiguous-ref behavior). **Sole ownership** is preserved by construction (a key lands in at most one new part / one survivor), and `playerNationId` is always re-pointed to a still-existing nation. The **stub is unchanged** — it still never proposes a nation op (`nationOps: []`) — so, like P4a/P4b before it, the new paths are covered by **unit tests only** and the e2e suite (CI, `LLM_STUB=1`) is unaffected.
|
||||||
|
- **Tests (`server/engine.test.ts`).** New `freshNationId`, `applyNationOp (split)`, and `applyNationOp (merge)` suites: the partition (first-wins, leftovers-to-part-0, sole ownership, fresh ids/stats/relations, parent removed, relations cleanup, player-pointer move, clamped `playerPart`, `edits.name` on the chosen part), the merge (union territory, survivor stats, relations union with survivor-wins, removed ids dropped from everyone else, into-name, player-pointer move, and the four no-op guards), plus an updated prompt-contract test asserting all three op shapes. The old "split/merge is a no-op" P4a test is retargeted to a 1-part split. The unit suite grows **151 → 164**.
|
||||||
|
- **Verification.** Typecheck, lint, **164 unit tests**, and the production build all pass. The e2e suite (CI, `LLM_STUB=1`) carries no nation-op assertions, so it stays green with the unchanged stub.
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
|
||||||
|
- Part of #16 — major nation changes (P4b, the final part of the epic). #16 was already closed (2026-10-12, after P3 shipped); the PR references it for traceability the same way P4a's PR #38 did ("Part of #16" / "Related: #16") — no auto-close.
|
||||||
|
|
||||||
|
## v0.23.0 — 2026-10-14 12:58
|
||||||
|
- **Part 4a of #16: nation renames — "LLM proposes, player confirms."** The LLM can now propose that a nation be renamed during a turn. Unlike stat/relation/territory changes (applied automatically), a rename is stored as a **pending proposal** (`GameState.pendingNationOps`) and only takes effect when the player **confirms** it (optionally editing the new name) or is dropped on **reject**. This is the first of the "major nation changes" the LLM may propose — the `NationOp` contract is stable now; **split** and **merge** are declared in the same union but are *accepted-and-stored, not applied* until P4b.
|
||||||
|
- **New `NationOp` union (`shared/protocol.ts`).** `rename` / `split` / `merge`, discriminated by `kind`. Each names its target via a `nationRef` the LLM may give as either a nation **id** or **name**. `TurnResult.nationOps` is a **required** `NationOp[]` (the model's proposals this turn); the confirm/reject request types (`NationOpConfirmRequest` `{index, edits?{name?}}`, `NationOpRejectRequest` `{index}`, response `{game}`) live here too.
|
||||||
|
- **`GameState.pendingNationOps` (`shared/types.ts`, type-only import of `NationOp`)** — the proposals awaiting the player's decision. A turn **appends** to it (never clears it on commit), so a proposal persists across turns until confirmed or rejected. Legacy SQLite rows and new games default it to `[]` (`server/db.ts`).
|
||||||
|
- **Engine (`server/engine.ts`), all pure.** `resolveNationRef` resolves a `nationRef` id-first, then a case-insensitive unique-name match (returns `null` for unknown/ambiguous — never throws). `validateNationOps` drops invalid ops defensively. `applyTurnResult` keeps existing pending ops and appends the turn's valid ones (a rename-only turn is `isMeaningful`, so it survives the retry/nudge path). `applyNationOp` mutates a clone (rename → `Nation.name` = edited value or the proposed `to`; split/merge are no-ops for now). `confirmNationOp`/`rejectNationOp` splice by index and bump `version` once (invalid index → `null`). The system prompt (both tool-mode and plain-mode) gains the `nationOps` JSON field and the rename-proposal rule.
|
||||||
|
- **Routes + client.** `POST /api/games/:id/nation-op/confirm` and `.../reject` (404 unknown game, 400 bad index), each returning `{ game }` and broadcasting a version-bumped SSE `state` — mirroring the existing queue add/remove routes. The "Your move" panel shows a "Nation proposals (N)" trigger that opens a dialog listing each pending op; a rename has an editable name field plus **Confirm**/**Reject**, and split/merge offer **Dismiss** (they aren't applied yet).
|
||||||
|
- **Stub + tests.** The deterministic stub **never** proposes a nation op (`nationOps: []`), so this path is covered by **unit tests only** (mirroring the P3 no-man's-land approach) — `server/engine.test.ts` gains suites for `resolveNationRef`, `validateNationOps`, `applyNationOp`/confirm/reject (incl. an invalid-index 400 and the version-bump), and the prompt contract. The unit suite grows **127 → 151**; the e2e suite is unaffected.
|
||||||
|
- **Verification.** Typecheck, lint, **151 unit tests**, and the production build all pass. The e2e suite (CI, `LLM_STUB=1`) has no nation-op assertions, so it stays green with the unchanged stub.
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
|
||||||
|
- Part of #16 — major nation changes (P4a); #16 stays open (P4b: split/merge).
|
||||||
|
|
||||||
|
## v0.22.0 — 2026-10-14 12:02
|
||||||
|
- **Part 3 of #16: no man's land — a region can be left unowned mid-game and re-claimed later.** After a war or cataclysm the LLM can leave a region without an owner: it lists the key in the new `TurnResult.unowned` field (and narrates the cause in `events`); the engine removes the key from its sole owner, and the region ends the turn ownerless. A later ordinary `gain` on that key is a **re-claim** — the key is granted to the (sole) gainer and removed from the no-man's-land list. A fresh game always starts fully owned (no man's land can only arise mid-game).
|
||||||
|
- **Engine (`server/engine.ts`).** `applyTurnResult` applies **releases first**: a key is released only when it has exactly one current owner (0 owners — a preset never assigned it — is a no-op, so no man's land can never be fabricated from an ordinary unowned region; 2+ owners — corruption — is a no-op; already-unowned is a no-op). Claims are then resolved against the post-release ownership snapshot: sole gainer + sole owner ≠ gainer = a transfer (the v0.21 rules, unchanged); sole gainer + no owner = a re-claim; a double claim is still rejected. Invariants after every turn: no key sits in two or more territories, a released key sits in none, and `GameState.unowned` holds exactly the keys released mid-game. A lone release counts as `isMeaningful` (it survives the retry/nudge path instead of being replaced by the generic "unclear" result). The system prompt gains the JSON field, the no-man's-land rule (in both tool-mode and plain-mode), and the hard constraint that `unowned` may contain only region keys a nation currently owns. The user payload always carries the current `GameState.unowned` as `unowned` (even when empty), and the jump route's `regions` reference table now includes the released keys so the model can name them when re-claiming.
|
||||||
|
- **New required `GameState.unowned` field** (`shared/types.ts`): the cumulative list of keys released mid-game. A no-man's-land region is **distinct from a preset-unassigned region** — the presets assign only ~25–30% of the 1,796 sub-regions (e.g. `wwii-1939` starts with 411 owned / 1,385 unowned), so ~1,300 ownerless regions exist at init; those keep the existing faint look, and only `GameState.unowned` keys get the distinct one. Legacy SQLite rows saved before v0.22 load with the field defaulted to `[]` (`server/db.ts`), and game creation sets it to `[]`.
|
||||||
|
- **Map presentation (a new contract in `shared/protocol.ts`).** `regionFillStyle(owner, isNoMansLand)` + `unownedTooltipLabel` decide the look: an owned region is unchanged (nation color + `OWNED_STROKE`); a released region gets the distinct visible grey `NO_MANS_LAND_FILL = '#3f3f46'` (hue-less by construction, so it can never collide with a nation's `hsl(h 58% 46%)` color) with a brighter border (`NO_MANS_LAND_STROKE`) and the tooltip sub-line "No man's land"; every other ownerless region keeps the faint `rgba(148,163,184,0.05)` fill and "No owner". The client (`WorldMap`, via a new `unowned` prop from `App.tsx`) is the sole consumer of the distinction — the `RegionMap` simply has no entry for an unowned key (`buildRegionMap` is unchanged). Clicking a no-man's-land region does not change the dossier (there is no owner to focus), and a release/re-claim flashes like any other ownership change.
|
||||||
|
- **Stub + tests.** The deterministic stub is deliberately **unchanged** — it never releases a region (it always returns `unowned: []` and ignores the payload's `unowned` field), because its one-conquest behavior is what the e2e assertions rely on; the release/re-claim path is therefore covered by **unit tests only** (a new `applyTurnResult (no man's land)` suite: release, re-claim, release+claim of the same key, a double claim on a released key, the four no-op release cases, a legacy state without the field, and a `runTurn` end-to-end test with a narrated release; new `buildUserPrompt`/`buildSystemPrompt` contract tests; a presentation-contract suite in `server/map.test.ts`; a legacy-row normalization test in `server/db.test.ts`). The unit suite grows **105 → 127**.
|
||||||
|
- **No data-format wipe.** The `GameState` shape only gains a field that legacy rows load with, so existing saves remain compatible: the deploy is a plain `up -d --build` (no `down -v`).
|
||||||
|
- **Verification.** Typecheck, lint, **127 unit tests**, and the production build all pass. The e2e suite (CI, `LLM_STUB=1`) carries no map-color/no-man's-land assertions, so it stays green with the unchanged stub.
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
|
||||||
|
- Part of #16 — no man's land (P3); #16 stays open.
|
||||||
|
|
||||||
|
## v0.21.0 — 2026-10-14 10:37
|
||||||
|
- **Part 2 of #16: territory now moves region-by-region along real land borders, and the LLM can verify borders with a live tool.** v0.19 made ownership region-keyed, but the model's `territoryChanges` were still applied wholesale — any key could jump to any nation, effectively moving whole countries. Now:
|
||||||
|
- **Region-adjacency manifest (Stage 1).** `scripts/build-region-adjacency.mjs` (`npm run build:region-adjacency`) computes which of the 1,796 sub-regions share a land border: per candidate pair, a cheap bbox-proximity prefilter (0.05° gap reject) then a `@turf/boolean-intersects` arbiter. The output `public/region-adjacency.json` is symmetric and deduped — **1,796 entries, 4,470 border pairs (8,940 directed), max degree 19; 1,727 regions have ≥1 neighbour, 69 don't** — and is deterministic/idempotent (sha256 `e9f1d475…`; re-running is byte-identical). Vite copies it into `dist/` for prod; `server/regions.ts` loads it (turf-free — plain `JSON.parse`, no `@turf` import in any `.ts`) and degrades gracefully when it is missing.
|
||||||
|
- **`getNeighbours` LLM tool (Stage 2).** `server/llm.ts` gains `LlmClient.chatWithTools` — a bounded OpenAI function-calling loop: on a `finish_reason: "tool_calls"` response the client runs the injected executor for every call, appends the assistant tool-call message plus one `tool` result message per call, and re-sends until a final `content` (max **6** tool rounds, then one last *no-tools* request forces the answer). It shares the retry/backoff/`reasoning_effort: "low"` machinery with `chat()`; a tool-call round is a success (never treated as truncation); malformed tool arguments become an `{"error":"invalid tool arguments…"}` tool message, so a bad model reply can never throw. `tools` is included in the request body only when non-empty, so plain `chat()` bodies (and their tests) are unchanged.
|
||||||
|
- **Engine wiring.** `runTurn` takes an optional `getNeighbours` resolver — the server's jump route passes one only when the manifest loaded (`hasAdjacency()`). With both the resolver and a `chatWithTools`-capable client, the turn runs the tool path: the system prompt gains the contiguity rules (a nation may only GAIN a key that borders its own territory; every key has exactly one owner) and advertises the tool; the user payload gains a `frontier` field — the player's `playerFrontier`, the sorted/deduped set of *other-nation* keys bordering the player's territory (the natural targets of a player-led conquest). Without the manifest, or with a minimal chat-only client, the turn falls back to the legacy plain `chat()` path — everything keeps working, the tool/frontier simply switch off.
|
||||||
|
- **Sole-ownership enforcement.** `applyTurnResult` now applies territory snapshot-first: before mutating anything it computes each key's current owner, and a key transfers only when (a) exactly one nation claims to gain it (a double claim is rejected — the key stays), (b) it has exactly one current owner (pre-existing double ownership is never touched), and (c) the owner is not the gainer (self-gain is a no-op). A transfer removes the key from all nations first, then grants it to the gainer; lone `lose` entries are no-ops. Every key therefore ends the turn with exactly one owner — it can never be orphaned or duplicated, and a bad model answer degrades to "no transfer", never to a corrupt map.
|
||||||
|
- **Deterministic stub.** The stub now makes one contiguous conquest per turn: the player gains `frontier[0]` (sorted → stable) and its owner loses it. It answers plain content (no tool calls), so it naturally bypasses the tool loop in a single round; with an empty frontier (no controllable border — e.g. the UK in `wwii-1939`, whose only external neighbour is Ireland) nothing transfers and every existing e2e assertion stays stable.
|
||||||
|
- **No data-format change — no save wipe.** The `TerritoryChange` wire shape is unchanged and saved games load as-is, so the deploy is a plain rebuild (no `down -v`).
|
||||||
|
- **Verification.** Typecheck, lint, **105 unit tests** (79 baseline + 26 new: tool-loop protocol — echo, parsed executor args, bounded rounds, malformed args, retry; engine tool-path wiring + fallback; `playerFrontier`; executor degradation; sole-ownership invariants; `neighboursOf`/`hasAdjacency`), the production build, and all **14 e2e** tests pass.
|
||||||
|
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
|
||||||
|
- Part of #16 — P3 (region rename/split/merge) and P4 follow; #16 stays open.
|
||||||
|
|
||||||
|
## v0.20.0 — 2026-10-14 09:30
|
||||||
|
- **Fixed: v0.19.0's region-level ownership did not activate in the production image.** The server-side region loader (`server/regions.ts`) only looked for the manifest + geo in `public/`, but in the Docker image Vite copies `public/*` → `dist/` and no `public/` directory is kept — so `loadRegions` found nothing, `expandCountriesToKeys` degraded to a no-op, and freshly-created games kept raw ISO_A3 codes (`DEU`, `GBR,IRL,IND`, …) instead of expanding to region keys. The choropleth therefore still rendered whole-country ownership and the model's region-key contract was inactive in prod (the client's own `/region-keys.json` fetch still worked, because the client is served from `dist/`). `loadRegions` now probes `public/` (dev) then `dist/` (prod, == `clientDir`) and uses the first directory holding both files, so one build works in both environments. A new test covers the prod-only (`dist/`), dev (`public/`), and empty-root (no data) layouts.
|
||||||
|
- **Deploy:** `docker compose down -v` (drops the `history-data` volume — pre-v0.19 saves were already dropped by v0.19.0 and are dropped again here) then `up -d --build`.
|
||||||
|
|
||||||
|
## v0.19.0 — 2026-10-14 09:14
|
||||||
|
- **Region-level ownership foundation (Part 1 of #16): internal territory ownership flips from whole countries to individual sub-regions.** Previously a nation owned *whole countries* (a `territory` list of ISO_A3 codes) and every sub-region of a country shared the parent's color, so a single region could never change owner independently. Now each owned sub-region is addressed by a stable **region key** — `<name-slug>-<4char>`, e.g. `moselle-rygy`, `ain-lqtt`, `western-alun` — generated deterministically by `scripts/build-region-keys.mjs` into `public/region-keys.json` (**1,796 unique keys** over the 1,796 sub-regions). A key is a slug of the region's name (NFKD, diacritics stripped, lowercased, non-alphanumerics → `-`) plus a 4-character hash of the feature's stable id, salted on within-country name collisions, so it is stable, human-readable, and globally unique (a "western" collision group yields `western-alun` / `western-qwvr` / `western-adsp` / …; an accented name like Shkodër becomes `shkoder-gmnr`).
|
||||||
|
- **The map, the model, and the LLM all now reason in region keys.** `Nation.territory` and the `RegionMap` are keyed by region key; the choropleth resolves each sub-region's owner by its stamped `properties.regionKey` (never the repacked numeric feature `id`, never the raw sub-region id — the #6 guard, now region-keyed). A new `server/regions.ts` (turf-free) loads the manifest + geo once at boot and **expands each nation's country list to all its sub-regions' keys at game creation**, so a fresh game shows every sub-region in its owner's color — visually identical to the old whole-country choropleth, but each sub-region is now individually addressable. The LLM prompt gains a `regions` reference table (region key → "<name> (parent ISO_A3)"), and `applyTurnResult` ignores any territory key not in the valid set, so a hallucinated key can't corrupt ownership.
|
||||||
|
- **The dossier shows region names.** NationPanel's territory chips display each sub-region's human name (e.g. "Ain", "Wales", "Moselle") with the raw key in the tooltip / data attribute, instead of bare codes.
|
||||||
|
- **⚠ Breaking — pre-v0.19 saved games are dropped.** The `territory` field's meaning changed from ISO_A3 country codes to region keys, so a game saved before this version can't be read by it. Deploy runs `docker compose down -v` (dropping the `history-data` volume) then `up -d --build`; there is no migration — start fresh after deploying.
|
||||||
|
- **Supersedes the abandoned `feat/sub-region-territory` branch**, which prototyped raw feature-id keys (`DEU-03`); this version uses the slugged, human-readable key scheme instead.
|
||||||
|
- **Verification.** Typecheck, lint, and the unit suite (now **71 tests across 7 files**: +`server/regions.test.ts`, a rewritten `server/map.test.ts`, +2 in `engine.test.ts`) all pass; the production build is clean. The deterministic `LLM_STUB` and the format-agnostic e2e suite (legend/stats/dates) are unchanged. **#16 stays open** — P2 (LLM tools / function-calling for reliable region ops) and the no-man's-land rename/split/merge phases follow.
|
||||||
|
|
||||||
## v0.18.0 — 2026-10-13 18:54
|
## 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.
|
- **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.
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.19.0",
|
"version": "0.27.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
@@ -14,6 +14,11 @@
|
|||||||
"cleanup:regions": "node scripts/cleanup-regions.mjs",
|
"cleanup:regions": "node scripts/cleanup-regions.mjs",
|
||||||
"smooth:regions": "node scripts/smooth-regions.mjs",
|
"smooth:regions": "node scripts/smooth-regions.mjs",
|
||||||
"fill:regions": "node scripts/fill-gaps.mjs",
|
"fill:regions": "node scripts/fill-gaps.mjs",
|
||||||
|
"split:regions": "node scripts/split-arkhangel.mjs",
|
||||||
|
"split:regions:band": "node scripts/split-arkhangel-band.mjs",
|
||||||
|
"build:region-keys": "node scripts/build-region-keys.mjs",
|
||||||
|
"build:region-adjacency": "node scripts/build-region-adjacency.mjs",
|
||||||
|
"build:region-centroids": "node scripts/build-region-centroids.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* scripts/build-region-adjacency.mjs — build the precomputed region-adjacency
|
||||||
|
* manifest: `public/region-adjacency.json` = `{ "<regionKey>": ["<neighbourKey>", ...], ... }`.
|
||||||
|
*
|
||||||
|
* For every sub-region (region key, see `public/region-keys.json`) this records
|
||||||
|
* which *other* sub-regions it **borders** — i.e. shares an edge or a point with.
|
||||||
|
* The result is the data behind the model's `getNeighbours` tool (P2 of #16):
|
||||||
|
* given a set of region keys, the engine returns the union of their adjacency
|
||||||
|
* entries (excluding the queried keys), so the LLM can plan *frontier-biased,
|
||||||
|
* contiguous* territory transfers (a nation realistically gains/loses a region
|
||||||
|
* it already abuts) instead of whole countries.
|
||||||
|
*
|
||||||
|
* Method — bounding-box-pruned polygon intersection (turf is allowed in .mjs
|
||||||
|
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
|
||||||
|
* v0.17 note). For each unordered pair of regions (i < j) we first do a cheap
|
||||||
|
* bbox-proximity reject with a small gap tolerance (adjacent regions' bboxes
|
||||||
|
* touch, so their gap is ≈0), and only for the survivors do we run
|
||||||
|
* `@turf/boolean-intersects`, which returns true exactly when the two
|
||||||
|
* (Multi)Polygons share at least one point (edge or corner touch, or overlap).
|
||||||
|
* A hit adds each key to the other's set — adjacency is recorded **symmetrically**
|
||||||
|
* and **deduplicated** (a neighbour appears once even if the pair shares several
|
||||||
|
* disjoint border segments).
|
||||||
|
*
|
||||||
|
* Output contract (asserted by scripts/region-adjacency.test.ts):
|
||||||
|
* - one entry for **every** region key (including regions with no bordering
|
||||||
|
* neighbours, e.g. isolated islands — their list is simply empty);
|
||||||
|
* - adjacency is symmetric (A ∈ B's list ⟺ B ∈ A's list);
|
||||||
|
* - no key lists itself.
|
||||||
|
*
|
||||||
|
* Deterministic + idempotent: the input files are fixed, the intersection test
|
||||||
|
* is a pure geometric computation, and the output is written with keys and
|
||||||
|
* neighbour lists both sorted — so a re-run rewrites a byte-identical file.
|
||||||
|
*
|
||||||
|
* Usage: `node scripts/build-region-adjacency.mjs` (or `npm run build:region-adjacency`).
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import bbox from '@turf/bbox';
|
||||||
|
import booleanIntersects from '@turf/boolean-intersects';
|
||||||
|
import { feature } from '@turf/helpers';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
// "Close enough" bbox gap (degrees) for the cheap reject. Adjacent regions'
|
||||||
|
// bboxes touch (gap ≈ 0); a small tolerance absorbs 4-dp coordinate rounding
|
||||||
|
// and digitization fuzz. It only prunes work — it never affects correctness,
|
||||||
|
// because `booleanIntersects` is the final arbiter (a pair is a neighbour only
|
||||||
|
// if the polygons actually share a point).
|
||||||
|
const GAP_DEG = 0.05; // ≈ 5.5 km
|
||||||
|
|
||||||
|
// --- load the geo + region-key manifest (plain parse; keys join feature->key) --
|
||||||
|
const geo = JSON.parse(readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'));
|
||||||
|
const idToKey = JSON.parse(readFileSync(path.join(root, 'public', 'region-keys.json'), 'utf8'));
|
||||||
|
|
||||||
|
// Region records in stable file order, keyed by their region key.
|
||||||
|
const regions = [];
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = idToKey[id];
|
||||||
|
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||||
|
regions.push({ key, feature: feature(f.geometry), bbox: bbox(f.geometry) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regions.length === 0) {
|
||||||
|
throw new Error('no regions with valid region keys — check regions.geo.json / region-keys.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// bboxes are flat [minX, minY, maxX, maxY] (turf's BBox type).
|
||||||
|
function bboxesClose(a, b) {
|
||||||
|
return (
|
||||||
|
a[0] <= b[2] + GAP_DEG && // a.w <= b.e + gap
|
||||||
|
b[0] <= a[2] + GAP_DEG && // b.w <= a.e + gap
|
||||||
|
a[1] <= b[3] + GAP_DEG && // a.s <= b.n + gap
|
||||||
|
b[1] <= a[3] + GAP_DEG // b.s <= a.n + gap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// key -> set of neighbouring keys (deduped; a pair sharing several segments is
|
||||||
|
// recorded once).
|
||||||
|
const adj = new Map();
|
||||||
|
for (const r of regions) adj.set(r.key, new Set());
|
||||||
|
|
||||||
|
let pairChecks = 0;
|
||||||
|
let intersections = 0;
|
||||||
|
for (let i = 0; i < regions.length; i++) {
|
||||||
|
for (let j = i + 1; j < regions.length; j++) {
|
||||||
|
if (!bboxesClose(regions[i].bbox, regions[j].bbox)) continue; // cheap reject
|
||||||
|
pairChecks++;
|
||||||
|
let touches = false;
|
||||||
|
try {
|
||||||
|
touches = booleanIntersects(regions[i].feature, regions[j].feature);
|
||||||
|
} catch {
|
||||||
|
touches = false; // a degenerate geometry pair is treated as non-adjacent
|
||||||
|
}
|
||||||
|
if (touches) {
|
||||||
|
intersections++;
|
||||||
|
adj.get(regions[i].key).add(regions[j].key);
|
||||||
|
adj.get(regions[j].key).add(regions[i].key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write idempotently: keys sorted, neighbour lists sorted ------------------
|
||||||
|
// Every region key gets an entry (an isolated island's list is simply empty).
|
||||||
|
const manifest = {};
|
||||||
|
for (const key of [...adj.keys()].sort()) {
|
||||||
|
manifest[key] = [...adj.get(key)].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-reference guard (can't happen — we only ever add the *other* region — but
|
||||||
|
// assert it so a future regression is loud rather than silent).
|
||||||
|
for (const [k, list] of Object.entries(manifest)) {
|
||||||
|
if (list.includes(k)) throw new Error(`region ${k} lists itself as a neighbour`);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(path.join(root, 'public', 'region-adjacency.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
|
||||||
|
// --- summary ------------------------------------------------------------------
|
||||||
|
const totalEntries = Object.values(manifest).reduce((n, l) => n + l.length, 0);
|
||||||
|
const withNeighbours = Object.values(manifest).filter((l) => l.length > 0).length;
|
||||||
|
const maxDegree = Math.max(0, ...Object.values(manifest).map((l) => l.length));
|
||||||
|
console.log(
|
||||||
|
`[build-region-adjacency] wrote public/region-adjacency.json — ${Object.keys(manifest).length} region keys, ` +
|
||||||
|
`${totalEntries / 2} border pairs (${totalEntries} directed entries), max degree ${maxDegree}, ` +
|
||||||
|
`${withNeighbours} with ≥1 neighbour (${pairChecks} bbox-close pairs tested, ${intersections} intersect)`,
|
||||||
|
);
|
||||||
|
// A couple of human-checkable examples (highest-degree regions, stable order).
|
||||||
|
const byDegree = Object.entries(manifest).sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
|
||||||
|
for (const [k, list] of byDegree.slice(0, 3)) {
|
||||||
|
console.log(` e.g. ${k} (deg ${list.length}) → ${list.slice(0, 4).join(', ')}${list.length > 4 ? ', …' : ''}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* scripts/build-region-centroids.mjs — build the precomputed region-centroid
|
||||||
|
* manifest: `public/region-centroids.json` = `{ "<regionKey>": { "lat": <number>, "lon": <number>, "spread": <number> }, ... }`.
|
||||||
|
*
|
||||||
|
* For every sub-region (region key, see `public/region-keys.json`) this records
|
||||||
|
* the point the LLM's map markers (v0.27, #44) will be anchored to: the
|
||||||
|
* **centroid of the region's largest-area part** (a MultiPolygon's biggest
|
||||||
|
* island/lake-piece rather than an area-weighted average of all parts), plus
|
||||||
|
* a `spread` value ≈ 0.1 × the smallest bbox dimension of that part (in
|
||||||
|
* degrees) — the engine scatters markers of one region within ±(spread/2)
|
||||||
|
* around the centroid using a deterministic per-marker offset, so several
|
||||||
|
* markers of the same region do not stack on one pixel.
|
||||||
|
*
|
||||||
|
* Method — plain turf on the pre-generated geo file (turf is allowed in .mjs
|
||||||
|
* scripts; it is deliberately kept OUT of the TS typecheck program, see the
|
||||||
|
* v0.17 note). For each feature we (1) split a MultiPolygon into its parts
|
||||||
|
* (a plain Polygon is its own single part), (2) pick the part with the
|
||||||
|
* greatest `@turf/area`, (3) take `@turf/centroid` of that part and
|
||||||
|
* `@turf/bbox` of that part, and (4) round everything to 6 decimal places
|
||||||
|
* (≈ 0.1 m at this scale — far below the map's visual precision).
|
||||||
|
*
|
||||||
|
* Output contract (asserted by scripts/region-centroids.test.ts):
|
||||||
|
* - one entry for **every** region key, keyed identically to
|
||||||
|
* `public/region-keys.json` (same key set, 1,800 entries);
|
||||||
|
* - each centroid lies inside its region's (largest part's) bbox;
|
||||||
|
* - `spread` is a non-negative number of degrees (0 for degenerate parts);
|
||||||
|
* - values are rounded to at most 6 decimal places.
|
||||||
|
*
|
||||||
|
* Deterministic + idempotent: the input files are fixed, turf is a pure
|
||||||
|
* geometric computation, the output keeps the geo file's (stable) feature
|
||||||
|
* order and rounds to fixed precision — so a re-run rewrites a byte-identical
|
||||||
|
* file (the test re-runs this script and compares bytes).
|
||||||
|
*
|
||||||
|
* Usage: `node scripts/build-region-centroids.mjs` (or `npm run build:region-centroids`).
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import area from '@turf/area';
|
||||||
|
import bbox from '@turf/bbox';
|
||||||
|
import centroid from '@turf/centroid';
|
||||||
|
import { feature } from '@turf/helpers';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
||||||
|
|
||||||
|
// --- load the geo + region-key manifest (plain parse; keys join feature->key) --
|
||||||
|
const geo = JSON.parse(readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'));
|
||||||
|
const idToKey = JSON.parse(readFileSync(path.join(root, 'public', 'region-keys.json'), 'utf8'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The polygon parts of a geometry: a MultiPolygon's individual polygons, a
|
||||||
|
* plain Polygon as its own single part, anything else as an empty list (the
|
||||||
|
* caller falls back to the whole-feature centroid).
|
||||||
|
*/
|
||||||
|
function partsOf(geometry) {
|
||||||
|
if (geometry?.type === 'MultiPolygon') return geometry.coordinates ?? [];
|
||||||
|
if (geometry?.type === 'Polygon') return [geometry.coordinates];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = {};
|
||||||
|
let skipped = 0;
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = idToKey[id];
|
||||||
|
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||||
|
const parts = partsOf(f.geometry);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
// Degenerate geometry (a point/line region): anchor at the feature's own
|
||||||
|
// centroid. Can't happen with the current 1,800-polygon dataset, but the
|
||||||
|
// fallback keeps the manifest complete if the generator ever changes.
|
||||||
|
skipped += 1;
|
||||||
|
const c = centroid(feature(f.geometry));
|
||||||
|
const b = bbox(feature(f.geometry));
|
||||||
|
manifest[key] = {
|
||||||
|
lat: round6(c.geometry.coordinates[1]),
|
||||||
|
lon: round6(c.geometry.coordinates[0]),
|
||||||
|
spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Pick the part with the greatest area (km²) and anchor to IT, so a
|
||||||
|
// multi-part region's markers cluster on its main mass, not between parts.
|
||||||
|
let best = 0;
|
||||||
|
let bestArea = -1;
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const a = area(feature({ type: 'Polygon', coordinates: parts[i] }));
|
||||||
|
if (a > bestArea) {
|
||||||
|
bestArea = a;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const part = feature({ type: 'Polygon', coordinates: parts[best] });
|
||||||
|
const c = centroid(part);
|
||||||
|
const b = bbox(part);
|
||||||
|
manifest[key] = {
|
||||||
|
lat: round6(c.geometry.coordinates[1]),
|
||||||
|
lon: round6(c.geometry.coordinates[0]),
|
||||||
|
spread: round6(Math.min(b[2] - b[0], b[3] - b[1]) * 0.1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hard assertions (the test re-checks these against the committed file) ---
|
||||||
|
const expectedKeys = Object.values(idToKey);
|
||||||
|
if (Object.keys(manifest).length !== expectedKeys.length) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${expectedKeys.length} centroid entries (one per region key), got ${Object.keys(manifest).length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const k of expectedKeys) {
|
||||||
|
if (!(k in manifest)) throw new Error(`missing centroid for region key ${JSON.stringify(k)}`);
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(manifest)) {
|
||||||
|
if (!Number.isFinite(v.lat) || !Number.isFinite(v.lon) || !Number.isFinite(v.spread)) {
|
||||||
|
throw new Error(`non-finite centroid for ${k}: ${JSON.stringify(v)}`);
|
||||||
|
}
|
||||||
|
if (v.spread < 0) throw new Error(`negative spread for ${k}: ${v.spread}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write idempotently (stable order = insertion order = geo file order) ---
|
||||||
|
writeFileSync(path.join(root, 'public', 'region-centroids.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
|
||||||
|
// --- summary ------------------------------------------------------------------
|
||||||
|
const spreads = Object.values(manifest).map((v) => v.spread);
|
||||||
|
console.log(
|
||||||
|
`[build-region-centroids] wrote public/region-centroids.json — ${Object.keys(manifest).length} region keys` +
|
||||||
|
(skipped ? ` (${skipped} non-polygon fallbacks)` : '') +
|
||||||
|
`, spread min/max ${Math.min(...spreads)} / ${Math.max(...spreads)} deg`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* scripts/build-region-keys.mjs — build the deterministic region-key manifest.
|
||||||
|
*
|
||||||
|
* Region ownership flips from whole-country (ISO_A3) to individual sub-region
|
||||||
|
* granularity, but the LLM and the persisted `Nation.territory` list need a
|
||||||
|
* *stable, human-readable, globally unique* identifier per sub-region. Raw
|
||||||
|
* feature ids (`FR-57`) are opaque to the model, so we mint a readable key of
|
||||||
|
* the form:
|
||||||
|
*
|
||||||
|
* <name-slug>-<4char> e.g. brittany-xk2a, western-alun
|
||||||
|
*
|
||||||
|
* where:
|
||||||
|
* - <name-slug> = properties.name → NFKD → strip combining marks → lowercase
|
||||||
|
* → a tiny fold table for the handful of letters NFKD does NOT decompose
|
||||||
|
* (ə ø ð đ œ ł, the only non-folding letters present in this dataset)
|
||||||
|
* → every non-alphanumeric run collapsed to a single '-' → leading/trailing
|
||||||
|
* '-' trimmed.
|
||||||
|
* - <4char> = a deterministic 4-letter lowercase code derived from the
|
||||||
|
* feature's stable id (FNV-1a 32-bit → 4 base-26 chars). It is stable
|
||||||
|
* (same id → same code on every run) because the key is persisted in
|
||||||
|
* SQLite and shown to the LLM. Within any name-slug group (e.g. the six
|
||||||
|
* regions all named "Western") the 4-char codes are made distinct by
|
||||||
|
* re-deriving with a deterministic salt on collision, which guarantees the
|
||||||
|
* full key is unique across all 1,800 regions.
|
||||||
|
*
|
||||||
|
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
|
||||||
|
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map
|
||||||
|
* is looked up by the client (`WorldMap`) and the server (`map`/`presets`/
|
||||||
|
* `engine`) to join a sub-region's stable id to its readable key.
|
||||||
|
*
|
||||||
|
* Builtins only — deliberately NO `@turf`/geojson import (importing one pulls
|
||||||
|
* `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see the
|
||||||
|
* v0.17 changelog note). Reads the geo file with plain `JSON.parse`.
|
||||||
|
*
|
||||||
|
* Idempotent: a re-run rewrites a byte-identical file (no timestamps, no
|
||||||
|
* randomness, stable feature order, deterministic hash).
|
||||||
|
*
|
||||||
|
* Usage: `node scripts/build-region-keys.mjs` (or `npm run build:region-keys`).
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Letters present in the dataset that NFKD does NOT decompose, and their
|
||||||
|
* single/multi-letter ASCII equivalents, so their slugs aren't hyphen-soup.
|
||||||
|
* Kept deliberately tiny + deterministic (only the six that actually occur).
|
||||||
|
*/
|
||||||
|
const FOLD = {
|
||||||
|
'ə': 'a', // schwa
|
||||||
|
'ø': 'o', // o-stroke
|
||||||
|
'ð': 'd', // eth
|
||||||
|
'đ': 'dj', // d-stroke
|
||||||
|
'œ': 'oe', // oe ligature
|
||||||
|
'ł': 'l', // l-stroke
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readable slug from a region name. NFKD decomposes + we strip the combining
|
||||||
|
* marks, lowercase, fold the non-decomposing letters, collapse every other
|
||||||
|
* non-alphanumeric run to one hyphen, and trim leading/trailing hyphens.
|
||||||
|
*/
|
||||||
|
function slug(name) {
|
||||||
|
let s = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
s = s.toLowerCase();
|
||||||
|
s = [...s].map((ch) => FOLD[ch] ?? ch).join('');
|
||||||
|
s = s.replace(/[^a-z0-9]+/g, '-');
|
||||||
|
s = s.replace(/^-+|-+$/g, '');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FNV-1a 32-bit — a tiny, stable, pure string hash. */
|
||||||
|
function fnv1a32(str) {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h ^= str.charCodeAt(i);
|
||||||
|
h = (h * 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */
|
||||||
|
function code4(h) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
out += String.fromCharCode(97 + ((((h >>> (i * 8)) & 0xff) % 26)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 4-char code for an id, deterministically salted on within-group collision. */
|
||||||
|
function codeFor(id, used) {
|
||||||
|
let code = code4(fnv1a32(id));
|
||||||
|
let attempt = 0;
|
||||||
|
while (used.has(code)) {
|
||||||
|
attempt += 1;
|
||||||
|
code = code4(fnv1a32(`${id}#${attempt}`));
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load the region geo (plain parse; no geojson types) ---
|
||||||
|
const geoFile = path.join(root, 'public', 'regions.geo.json');
|
||||||
|
const geo = JSON.parse(readFileSync(geoFile, 'utf8'));
|
||||||
|
const features = Array.isArray(geo?.features) ? geo.features : [];
|
||||||
|
|
||||||
|
if (features.length !== 1800) {
|
||||||
|
throw new Error(`expected 1,800 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group features by their (computed) name-slug, preserving file order, so the
|
||||||
|
// within-slug 4-char uniqueness resolution below is deterministic.
|
||||||
|
const groups = new Map(); // slug -> array of { id }
|
||||||
|
for (const f of features) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const name = f.properties?.name;
|
||||||
|
if (!id || typeof name !== 'string') {
|
||||||
|
throw new Error(`region with id ${JSON.stringify(id)} has no usable name`);
|
||||||
|
}
|
||||||
|
const s = slug(name);
|
||||||
|
if (!groups.has(s)) groups.set(s, []);
|
||||||
|
groups.get(s).push({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign keys, resolving 4-char collisions within each slug group.
|
||||||
|
const manifest = {};
|
||||||
|
for (const [s, ids] of groups) {
|
||||||
|
const used = new Set();
|
||||||
|
for (const { id } of ids) {
|
||||||
|
manifest[id] = `${s}-${codeFor(id, used)}`;
|
||||||
|
used.add(manifest[id].slice(-4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hard assertions (the test re-checks these against the committed file) ---
|
||||||
|
const keys = Object.values(manifest);
|
||||||
|
if (new Set(keys).size !== keys.length) {
|
||||||
|
throw new Error(`region-key collision: ${keys.length} keys but only ${new Set(keys).size} unique`);
|
||||||
|
}
|
||||||
|
const KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[a-z]{4}$/;
|
||||||
|
for (const k of keys) {
|
||||||
|
if (!KEY_RE.test(k) || !k.includes('-')) {
|
||||||
|
throw new Error(`malformed region key: ${JSON.stringify(k)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write idempotently (stable order = insertion order = file order) ---
|
||||||
|
const out = JSON.stringify(manifest, null, 2) + '\n';
|
||||||
|
writeFileSync(path.join(root, 'public', 'region-keys.json'), out);
|
||||||
|
|
||||||
|
const western = Object.entries(manifest)
|
||||||
|
.filter(([, k]) => k.startsWith('western-'))
|
||||||
|
.sort((a, b) => a[0] < b[0] ? -1 : 1);
|
||||||
|
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,800 expected)`);
|
||||||
|
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* scripts/region-adjacency.test.ts — validation for the committed
|
||||||
|
* `public/region-adjacency.json` (built by `scripts/build-region-adjacency.mjs`).
|
||||||
|
*
|
||||||
|
* The manifest maps each region key to the list of region keys it **borders**
|
||||||
|
* (shares an edge/point with). It is the data behind the model's `getNeighbours`
|
||||||
|
* tool (v0.21, #16 P2), so it is a load-bearing data artifact — this test pins
|
||||||
|
* its contract.
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free: it reads the JSON with plain `fs`/`JSON.parse`
|
||||||
|
* and never imports `@turf`/geojson types (importing one pulls `@types/geojson`
|
||||||
|
* into the tsc program and breaks `WorldMap.tsx`; see the v0.17 changelog note).
|
||||||
|
* Mirrors the style of `scripts/region-keys.test.ts`.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
function readJson(rel: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('region-adjacency.json', () => {
|
||||||
|
const adjacency = readJson('public/region-adjacency.json') as Record<string, string[]>;
|
||||||
|
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||||
|
const keys = new Set(Object.values(manifest));
|
||||||
|
|
||||||
|
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
|
||||||
|
const manifestKeys = new Set(Object.values(manifest));
|
||||||
|
const adjacencyKeys = new Set(Object.keys(adjacency));
|
||||||
|
expect(adjacencyKeys).toEqual(manifestKeys);
|
||||||
|
expect(Object.keys(adjacency)).toHaveLength(keys.size);
|
||||||
|
expect(adjacencyKeys.size).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every neighbour is itself a valid region key (referential integrity)', () => {
|
||||||
|
for (const [key, neighbours] of Object.entries(adjacency)) {
|
||||||
|
for (const n of neighbours) {
|
||||||
|
expect(keys.has(n), `neighbour ${n} of ${key} is not a valid region key`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no region lists itself as a neighbour', () => {
|
||||||
|
for (const [key, neighbours] of Object.entries(adjacency)) {
|
||||||
|
expect(neighbours.includes(key), `${key} lists itself`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjacency is symmetric (A ∈ B ⟺ B ∈ A)', () => {
|
||||||
|
let checked = 0;
|
||||||
|
for (const [a, aList] of Object.entries(adjacency)) {
|
||||||
|
const aSet = new Set(aList);
|
||||||
|
for (const b of aList) {
|
||||||
|
const bList = adjacency[b];
|
||||||
|
expect(Array.isArray(bList), `${b} has no adjacency entry`).toBe(true);
|
||||||
|
// A lists B, so B must list A.
|
||||||
|
expect(bList!.includes(a), `${a} → ${b} but ${b} does not list ${a}`).toBe(true);
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
// And the converse: every neighbour B of A must be a key (implied above) —
|
||||||
|
// also assert no *missing* reverse entry by checking the full set equality.
|
||||||
|
const bSet = new Set(aSet);
|
||||||
|
for (const b of aList) bSet.add(b);
|
||||||
|
// Symmetry means a's set equals the set of {b : a ∈ b}.
|
||||||
|
const reverse = Object.entries(adjacency)
|
||||||
|
.filter(([, list]) => list.includes(a))
|
||||||
|
.map(([k]) => k);
|
||||||
|
expect(new Set(reverse).size, `asymmetric degree for ${a}`).toBe(aList.length);
|
||||||
|
expect(new Set(aList).size).toBe(aSet.size); // no duplicate neighbours
|
||||||
|
}
|
||||||
|
expect(checked).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spot-checks known real bordering pairs', () => {
|
||||||
|
// Brazil ↔ Peru (Acre borders Loreto) — a real, unambiguous land border.
|
||||||
|
expect(adjacency['acre-ekzw']?.includes('loreto-ipkx')).toBe(true);
|
||||||
|
expect(adjacency['loreto-ipkx']?.includes('acre-ekzw')).toBe(true);
|
||||||
|
// France ↔ Germany (Moselle borders Baden-Württemberg) — another real border.
|
||||||
|
expect(adjacency['moselle-rygy']?.includes('baden-wurttemberg-oyje')).toBe(true);
|
||||||
|
expect(adjacency['baden-wurttemberg-oyje']?.includes('moselle-rygy')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the #31 + #41 Arctic splits: New Earth isolated; Severnaya Zemlya, Pechora and Yamal adjacency", () => {
|
||||||
|
// v0.25 (#31) split RU-ARK into the mainland (arkhangel-sk-lhbk), the
|
||||||
|
// Franz Josef Land islets (new-earth-eikt) and the Severnaya Zemlya
|
||||||
|
// islets (severnaya-zemlya-awqp). New Earth sits ~100+ km from any land
|
||||||
|
// → a genuinely isolated island (empty list).
|
||||||
|
// v0.26 (#41) repartitioned the mainland: the 5 lat < 68 parts stay
|
||||||
|
// arkhangel-sk-lhbk, and the 28 lat ≥ 68 band parts became pechora-qwhs
|
||||||
|
// (the SW cell) and yamal-gosw (the NE cell). The Severnaya Zemlya
|
||||||
|
// islets' digitized rings touch the NE band part (now yamal-gosw), not
|
||||||
|
// the trimmed core, so SZ now borders Yamal (its former mainland contact
|
||||||
|
// moved to Yamal). The trimmed core keeps its real mainland neighbours
|
||||||
|
// but loses the SZ contact. Pechora (SW) borders the existing Nenets
|
||||||
|
// region to the south and Yamal to the north.
|
||||||
|
expect(adjacency['new-earth-eikt']).toEqual([]);
|
||||||
|
expect(adjacency['severnaya-zemlya-awqp']).toEqual(['yamal-gosw']);
|
||||||
|
expect(adjacency['pechora-qwhs']).toEqual(['nenets-qyga', 'yamal-gosw']);
|
||||||
|
expect(adjacency['yamal-gosw']).toEqual(['pechora-qwhs', 'severnaya-zemlya-awqp']);
|
||||||
|
// the trimmed core keeps its real neighbours (the band's SZ contact moved to Yamal)
|
||||||
|
expect(adjacency['arkhangel-sk-lhbk']).toEqual([
|
||||||
|
'karelia-ohem',
|
||||||
|
'kirov-gfvm',
|
||||||
|
'komi-eoeh',
|
||||||
|
'murmansk-etas',
|
||||||
|
'nenets-qyga',
|
||||||
|
'vologda-ynvr',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* scripts/region-centroids.test.ts — validation for the committed
|
||||||
|
* `public/region-centroids.json` (built by `scripts/build-region-centroids.mjs`).
|
||||||
|
*
|
||||||
|
* The manifest maps each region key to the anchor point a map marker (v0.27,
|
||||||
|
* #44) is placed at: the centroid of the region's largest-area part, plus a
|
||||||
|
* `spread` (≈ 0.1 × that part's smallest bbox dimension, in degrees) that the
|
||||||
|
* engine uses to scatter multiple markers of one region around the centroid.
|
||||||
|
* It is a load-bearing data artifact for the marker placement path — this test
|
||||||
|
* pins its contract.
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free: it reads the JSON with plain `fs`/`JSON.parse`
|
||||||
|
* and computes bbox checks by iterating coordinates directly (no `@turf`/
|
||||||
|
* geojson types, which would pull `@types/geojson` into the tsc program and
|
||||||
|
* break `WorldMap.tsx` — see the v0.17 note). Mirrors the style of
|
||||||
|
* `scripts/region-adjacency.test.ts`; the determinism test re-runs the (turf
|
||||||
|
* `.mjs`) builder and compares bytes, matching that the builder is idempotent.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
function readJson(rel: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CentroidEntry {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
spread: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All [lon, lat] coordinate pairs of a Polygon / MultiPolygon geometry. */
|
||||||
|
function allCoords(geometry: { type?: string; coordinates?: unknown }): Array<[number, number]> {
|
||||||
|
const c = geometry?.coordinates as
|
||||||
|
| Array<Array<[number, number]>>
|
||||||
|
| Array<Array<Array<[number, number]>>>
|
||||||
|
| undefined;
|
||||||
|
if (!c) return [];
|
||||||
|
if (geometry?.type === 'Polygon') {
|
||||||
|
const rings = c as Array<Array<[number, number]>>;
|
||||||
|
const out: Array<[number, number]> = [];
|
||||||
|
for (const ring of rings) for (const p of ring) out.push(p);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (geometry?.type === 'MultiPolygon') {
|
||||||
|
const polys = c as Array<Array<Array<[number, number]>>>;
|
||||||
|
const out: Array<[number, number]> = [];
|
||||||
|
for (const poly of polys) for (const ring of poly) for (const p of ring) out.push(p);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bounding box [minLon, minLat, maxLon, maxLat] of a geometry. */
|
||||||
|
function bboxOf(geometry: { type?: string; coordinates?: unknown }): [number, number, number, number] {
|
||||||
|
let minLon = Infinity;
|
||||||
|
let minLat = Infinity;
|
||||||
|
let maxLon = -Infinity;
|
||||||
|
let maxLat = -Infinity;
|
||||||
|
for (const [x, y] of allCoords(geometry)) {
|
||||||
|
if (x < minLon) minLon = x;
|
||||||
|
if (y < minLat) minLat = y;
|
||||||
|
if (x > maxLon) maxLon = x;
|
||||||
|
if (y > maxLat) maxLat = y;
|
||||||
|
}
|
||||||
|
return [minLon, minLat, maxLon, maxLat];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('region-centroids.json', () => {
|
||||||
|
const centroids = readJson('public/region-centroids.json') as Record<string, CentroidEntry>;
|
||||||
|
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||||
|
const geo = readJson('public/regions.geo.json') as {
|
||||||
|
features?: Array<{ id?: string | number; geometry?: { type?: string; coordinates?: unknown } }>;
|
||||||
|
};
|
||||||
|
const keys = Object.values(manifest);
|
||||||
|
|
||||||
|
// featureId -> feature, then regionKey -> feature (via the key manifest).
|
||||||
|
const featureByKey = new Map<string, { geometry?: { type?: string; coordinates?: unknown } }>();
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const key = manifest[String(f.id)];
|
||||||
|
if (key) featureByKey.set(key, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('has exactly one entry per region key (same key set as region-keys.json)', () => {
|
||||||
|
expect(new Set(Object.keys(centroids))).toEqual(new Set(keys));
|
||||||
|
expect(Object.keys(centroids)).toHaveLength(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every entry is a finite { lat, lon, spread } with a non-negative spread, in world bounds', () => {
|
||||||
|
for (const [k, v] of Object.entries(centroids)) {
|
||||||
|
expect(Number.isFinite(v.lat), `non-finite lat for ${k}`).toBe(true);
|
||||||
|
expect(Number.isFinite(v.lon), `non-finite lon for ${k}`).toBe(true);
|
||||||
|
expect(Number.isFinite(v.spread), `non-finite spread for ${k}`).toBe(true);
|
||||||
|
expect(v.spread >= 0, `negative spread for ${k}`).toBe(true);
|
||||||
|
expect(v.lat >= -90 && v.lat <= 90, `lat out of range for ${k}: ${v.lat}`).toBe(true);
|
||||||
|
expect(v.lon >= -180 && v.lon <= 180, `lon out of range for ${k}: ${v.lon}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each centroid lies inside its region feature bounding box', () => {
|
||||||
|
// The recorded point is the centroid of the LARGEST part, which is a subset
|
||||||
|
// of the whole feature, so it must fall inside the feature's overall bbox.
|
||||||
|
let checked = 0;
|
||||||
|
for (const [key, c] of Object.entries(centroids)) {
|
||||||
|
const feat = featureByKey.get(key);
|
||||||
|
if (!feat?.geometry) continue;
|
||||||
|
const [minLon, minLat, maxLon, maxLat] = bboxOf(feat.geometry);
|
||||||
|
const eps = 1e-4; // allow for the 6-dp rounding in the builder
|
||||||
|
expect(
|
||||||
|
c.lat >= minLat - eps && c.lat <= maxLat + eps,
|
||||||
|
`lat ${c.lat} outside bbox [${minLat}, ${maxLat}] for ${key}`,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
c.lon >= minLon - eps && c.lon <= maxLon + eps,
|
||||||
|
`lon ${c.lon} outside bbox [${minLon}, ${maxLon}] for ${key}`,
|
||||||
|
).toBe(true);
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
expect(checked).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spot-checks a couple of well-known anchors land in the right place', () => {
|
||||||
|
// Region keys are sub-national (states/provinces), so we spot-check a few
|
||||||
|
// whose geography is unambiguous. A gross error in the centroid method
|
||||||
|
// (e.g. averaging across all parts of a large multi-part region, or a
|
||||||
|
// lon/lat swap) would land one of these far off.
|
||||||
|
const near = (k: string, lat: number, lon: number, tol: number) => {
|
||||||
|
const c = centroids[k];
|
||||||
|
expect(c, `missing centroid for ${k}`).toBeDefined();
|
||||||
|
expect(Math.abs(c!.lat - lat)).toBeLessThan(tol);
|
||||||
|
expect(Math.abs(c!.lon - lon)).toBeLessThan(tol);
|
||||||
|
};
|
||||||
|
near('ain-lqtt', 46.16, 4.8, 0.5); // FR-01 — eastern France
|
||||||
|
near('california-kikh', 36.87, -120.67, 0.7); // US-CA — central California
|
||||||
|
near('alberta-zopq', 52.09, -116.94, 1.5); // CA-AB — Alberta (large province)
|
||||||
|
near('acre-ekzw', -9.49, -71.11, 0.5); // BR-AC — western Brazil
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic: re-running the builder rewrites a byte-identical file', () => {
|
||||||
|
const file = path.join(root, 'public', 'region-centroids.json');
|
||||||
|
const before = readFileSync(file, 'utf8');
|
||||||
|
// The builder is cwd-independent (it resolves the repo root from its own
|
||||||
|
// location) and idempotent: same input files + pure turf + fixed rounding
|
||||||
|
// + stable insertion order → identical bytes.
|
||||||
|
execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-centroids.mjs')], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
const after = readFileSync(file, 'utf8');
|
||||||
|
expect(after).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* scripts/region-keys.test.ts — validation for the committed `public/region-keys.json`.
|
||||||
|
*
|
||||||
|
* The manifest maps each sub-region's **stable feature id** (from
|
||||||
|
* `public/regions.geo.json`) to a deterministic, human-readable, globally
|
||||||
|
* unique region key of the form `<name-slug>-<4char>` (built by
|
||||||
|
* `scripts/build-region-keys.mjs`). Region-level ownership (v0.19) keys both
|
||||||
|
* `Nation.territory` and the `RegionMap` on these keys, so the manifest is a
|
||||||
|
* load-bearing data artifact — this test pins its contract.
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free: it reads both JSON files with plain
|
||||||
|
* `fs`/`JSON.parse` and never imports `@turf`/geojson types (importing one
|
||||||
|
* pulls `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see
|
||||||
|
* the v0.17 changelog note). Mirrors the style of `scripts/regions.test.ts`.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
|
||||||
|
function readJson(rel: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('region-keys.json', () => {
|
||||||
|
const manifest = readJson('public/region-keys.json') as Record<string, string>;
|
||||||
|
const geo = readJson('public/regions.geo.json') as {
|
||||||
|
features?: Array<{ id?: string | number }>;
|
||||||
|
};
|
||||||
|
const keys = Object.values(manifest);
|
||||||
|
const featureIds = (geo.features ?? []).map((f) => String(f.id));
|
||||||
|
|
||||||
|
it('has exactly 1,800 entries (one per region)', () => {
|
||||||
|
expect(Object.keys(manifest)).toHaveLength(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has 1,800 unique keys (no collisions)', () => {
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
expect(new Set(keys).size).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keys each end in -[a-z]{4} and contain at least one hyphen', () => {
|
||||||
|
const ending = /-[a-z]{4}$/;
|
||||||
|
for (const k of keys) {
|
||||||
|
expect(k, `key does not end in -[a-z]{4}: ${k}`).toMatch(ending);
|
||||||
|
expect(k, `key has no hyphen: ${k}`).toContain('-');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps RU-ARK's key stable across the #31 + #41 splits and mints the island and band keys", () => {
|
||||||
|
// v0.25 (#31) split RU-ARK into main + RU-ARK-NE + RU-ARK-SZ, and v0.26
|
||||||
|
// (#41) repartitioned the mainland into the core (RU-ARK) + RU-PEO +
|
||||||
|
// RU-YAM. The MAIN feature keeps its id (and therefore its key — FNV-1a
|
||||||
|
// of the id), which is what keeps existing Nation.territory / SQLite
|
||||||
|
// ownership valid; the new features get fresh keys derived the same way
|
||||||
|
// from their ids (no within-country name-collision salt needed — the
|
||||||
|
// slugs are unique).
|
||||||
|
expect(manifest['RU-ARK']).toBe('arkhangel-sk-lhbk'); // unchanged since v0.19
|
||||||
|
expect(manifest['RU-ARK-NE']).toBe('new-earth-eikt');
|
||||||
|
expect(manifest['RU-ARK-SZ']).toBe('severnaya-zemlya-awqp');
|
||||||
|
expect(manifest['RU-PEO']).toBe('pechora-qwhs');
|
||||||
|
expect(manifest['RU-YAM']).toBe('yamal-gosw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a stable join: its keys are exactly the regions.geo.json feature ids', () => {
|
||||||
|
// The manifest is looked up by feature id, so its key set must line up
|
||||||
|
// 1:1 with the geo file (catches drift if the geo is regenerated without
|
||||||
|
// rebuilding the keys).
|
||||||
|
expect(featureIds).toHaveLength(1800);
|
||||||
|
const manifestIds = Object.keys(manifest);
|
||||||
|
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
|
||||||
|
});
|
||||||
|
});
|
||||||
+70
-12
@@ -530,16 +530,22 @@ describe('regions.geo.json', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is at the v0.17 fixed point: 1,796 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole (KZ-KZY)', () => {
|
it('is at the v0.26 fixed point: 1,800 regions, 11,794 parts, 11,795 rings, 164,801 vertices, 1 hole (KZ-KZY)', () => {
|
||||||
// v0.17 (scripts/fill-gaps.mjs) APPENDED cleaned gap pieces to the v0.16
|
// v0.17 (scripts/fill-gaps.mjs) APPENDED cleaned gap pieces to the v0.16
|
||||||
// fixed point: it never moves an existing border, merges a part, or
|
// fixed point: it never moves an existing border, merges a part, or
|
||||||
// removes a ring — it only adds new parts (+6,768: 5,026 → 11,794) and
|
// removes a ring — it only adds new parts (+6,768: 5,026 → 11,794) and
|
||||||
// keeps the single KZ-KZY hole. The region count (1,796), the
|
// keeps the single KZ-KZY hole. The v0.25 Arkhangel'sk split (#31,
|
||||||
// per-feature ids/names/parents and their order are unchanged; only the
|
// scripts/split-arkhangel.mjs) then repartitioned the 78 RU-ARK parts
|
||||||
// part/ring/vertex totals and the hole's part structure moved. Pinning
|
// across three features (RU-ARK + RU-ARK-NE + RU-ARK-SZ), and the v0.26
|
||||||
// the signature guards against a non-idempotent or destructive re-run.
|
// band split (#41, scripts/split-arkhangel-band.mjs) repartitioned the
|
||||||
// The fill is byte-deterministic per input and idempotent (a second run
|
// 33-part mainland again into RU-ARK + RU-PEO + RU-YAM. Both are pure
|
||||||
// on the filled file changes nothing).
|
// re-groupings: no part/ring/vertex moves, so only the region count
|
||||||
|
// changed (1,796 → 1,798 → 1,800); the part/ring/vertex totals, the
|
||||||
|
// per-feature ids/names/parents, and the feature order (the new features
|
||||||
|
// sit immediately after RU-ARK-SZ) are as pinned below. Pinning the
|
||||||
|
// signature guards against a non-idempotent or destructive re-run; both
|
||||||
|
// stages are byte-deterministic per input and idempotent (a second run
|
||||||
|
// changes nothing).
|
||||||
const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
|
const polysOf = (g: Geometry) => (g.type === 'Polygon' ? [g.coordinates] : g.coordinates);
|
||||||
let verts = 0,
|
let verts = 0,
|
||||||
rings = 0,
|
rings = 0,
|
||||||
@@ -556,15 +562,67 @@ describe('regions.geo.json', () => {
|
|||||||
for (const ring of poly) verts += ring.length;
|
for (const ring of poly) verts += ring.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(regions.features.length, 'region count').toBe(1796); // unchanged by the fill
|
expect(regions.features.length, 'region count').toBe(1800); // 1,796 + the #31 islands (+2) + the #41 band regions (+2)
|
||||||
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces
|
expect(parts, 'parts').toBe(11_794); // v0.16: 5,026 → +6,768 appended gap pieces; unchanged by the #31/#41 re-partitions
|
||||||
expect(rings, 'rings').toBe(11_795); // every appended part is a single-ring polygon (+1 hole = KZ-KZY, pre-existing)
|
expect(rings, 'rings').toBe(11_795); // every appended part is a single-ring polygon (+1 hole = KZ-KZY, pre-existing)
|
||||||
expect(holes, 'holes').toBe(1); // the single KZ-KZY hole, unchanged
|
expect(holes, 'holes').toBe(1); // the single KZ-KZY hole, unchanged
|
||||||
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
|
expect(holeIds, 'the one remaining hole is KZ-KZY').toEqual(['KZ-KZY']);
|
||||||
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175
|
expect(verts, 'total vertices (incl. ring closure)').toBe(164_801); // v0.16: 111,175; unchanged by the #31/#41 re-partitions
|
||||||
console.log(
|
console.log(
|
||||||
`\n v0.17 gap fill: ${regions.features.length} regions, ${parts} parts, ${rings} rings, ` +
|
`\n v0.26 fixed point: ${regions.features.length} regions (1,796 + the #31 islands + the #41 band), ` +
|
||||||
`${holes} hole (KZ-KZY), ${verts} vertices — v0.16 topology preserved, 6,768 gap pieces appended`,
|
`${parts} parts, ${rings} rings, ${holes} hole (KZ-KZY), ${verts} vertices — v0.17 topology preserved`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the five Arkhangel\'sk-family features: #31 islands + #41 band (RU-ARK, New Earth, Severnaya Zemlya, Pechora, Yamal)', () => {
|
||||||
|
// v0.25 (#31): the ~411,500 km² RU-ARK mega-region (a 78-part MultiPolygon
|
||||||
|
// mixing mainland and far-north islands) was repartitioned by part
|
||||||
|
// centroid into the mainland (RU-ARK, keeps its id/name/key), the Franz
|
||||||
|
// Josef Land islets (RU-ARK-NE "New Earth", centroid lat ≥ 79.5) and the
|
||||||
|
// Severnaya Zemlya islets (RU-ARK-SZ, centroid 76–79.5° N, lon ≥ 55°E).
|
||||||
|
// v0.26 (#41): the 33-part mainland (post-#31) was repartitioned again —
|
||||||
|
// the 5 lat < 68 parts stay RU-ARK (id/name/key unchanged), and the 28
|
||||||
|
// lat ≥ 68 "Arctic-coast band" parts split into RU-PEO "Pechora" (the SW
|
||||||
|
// Voronoi cell, 13 parts) and RU-YAM "Yamal" (the NE Voronoi cell, 15
|
||||||
|
// parts). Pure re-grouping: all five keep parent RUS and all 78 original
|
||||||
|
// parts of the pre-#31 feature are accounted for, verbatim.
|
||||||
|
const byId = new Map(regions.features.map((f) => [f.id, f]));
|
||||||
|
const ark = byId.get('RU-ARK');
|
||||||
|
const ne = byId.get('RU-ARK-NE');
|
||||||
|
const sz = byId.get('RU-ARK-SZ');
|
||||||
|
const peo = byId.get('RU-PEO');
|
||||||
|
const yam = byId.get('RU-YAM');
|
||||||
|
for (const f of [ark, ne, sz, peo, yam]) {
|
||||||
|
expect(f, `${f?.id ?? '?'} exists`).toBeDefined();
|
||||||
|
expect(f!.properties.parent, `${f!.id} parent`).toBe('RUS');
|
||||||
|
expect(f!.geometry.type, `${f!.id} geometry`).toBe('MultiPolygon');
|
||||||
|
}
|
||||||
|
expect(ark!.properties.name, 'RU-ARK keeps its name').toBe("Arkhangel'sk");
|
||||||
|
expect(ne!.properties.name, 'RU-ARK-NE name').toBe('New Earth');
|
||||||
|
expect(sz!.properties.name, 'RU-ARK-SZ name').toBe('Severnaya Zemlya');
|
||||||
|
expect(peo!.properties.name, 'RU-PEO name').toBe('Pechora');
|
||||||
|
expect(yam!.properties.name, 'RU-YAM name').toBe('Yamal');
|
||||||
|
// the 78 parts of the pre-split feature are all accounted for, verbatim
|
||||||
|
const countParts = (f: (typeof regions.features)[number]) =>
|
||||||
|
f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates.length : 1;
|
||||||
|
expect(
|
||||||
|
countParts(ark!) + countParts(ne!) + countParts(sz!) + countParts(peo!) + countParts(yam!),
|
||||||
|
'total parts across the five features',
|
||||||
|
).toBe(78);
|
||||||
|
// the #41 split: the mainland core keeps its 5 lat < 68 parts; the band's
|
||||||
|
// 28 parts split 13 (Pechora) / 15 (Yamal)
|
||||||
|
expect(countParts(ark!), 'RU-ARK core part count').toBe(5);
|
||||||
|
expect(countParts(peo!), 'RU-PEO part count').toBe(13);
|
||||||
|
expect(countParts(yam!), 'RU-YAM part count').toBe(15);
|
||||||
|
// stable file order: RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM
|
||||||
|
const idx = regions.features.findIndex((f) => f.id === 'RU-ARK');
|
||||||
|
expect(regions.features[idx + 1]?.id).toBe('RU-ARK-NE');
|
||||||
|
expect(regions.features[idx + 2]?.id).toBe('RU-ARK-SZ');
|
||||||
|
expect(regions.features[idx + 3]?.id).toBe('RU-PEO');
|
||||||
|
expect(regions.features[idx + 4]?.id).toBe('RU-YAM');
|
||||||
|
console.log(
|
||||||
|
`\n #31 + #41: RU-ARK ${countParts(ark!)} parts (core), RU-ARK-NE ${countParts(ne!)} (New Earth), ` +
|
||||||
|
`RU-ARK-SZ ${countParts(sz!)} (Severnaya Zemlya), RU-PEO ${countParts(peo!)} (Pechora), RU-YAM ${countParts(yam!)} (Yamal)`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/split-arkhangel-band.mjs — split the RU-ARK Arctic-coast band into
|
||||||
|
* two new regions, RU-PEO ("Pechora", the SW group) and RU-YAM ("Yamal", the
|
||||||
|
* NE group) (issue #41, a follow-up to #31).
|
||||||
|
*
|
||||||
|
* The #31 split (`scripts/split-arkhangel.mjs`) left `RU-ARK` as a 33-part
|
||||||
|
* MultiPolygon (~393,256 km²). Measured on that feature (centroid latitude /
|
||||||
|
* planar area), the parts divide cleanly by centroid latitude:
|
||||||
|
*
|
||||||
|
* lat < 68 4 parts ~305,000 km² (Arkhangel'sk proper + 3 slivers)
|
||||||
|
* 66 ≤ lat < 68 1 part ~47 km²
|
||||||
|
* lat ≥ 68 28 parts ~88,000 km² (the "Arctic-coast band")
|
||||||
|
*
|
||||||
|
* The band is really *other* Arctic oblasts (Nenets / Pechora / Yamal / Taimyr
|
||||||
|
* side) mis-lumped into Arkhangel'sk during the v0.14 coarsening. This stage
|
||||||
|
* re-coarsens it: the 28 band parts are split into two new regions by a
|
||||||
|
* Voronoi of the two band parts with the largest planar area (the SW anchor
|
||||||
|
* vs the NE anchor), while the 5 lat < 68 parts stay in `RU-ARK`.
|
||||||
|
*
|
||||||
|
* RU-ARK (keeps its id and name) — the 5 lat < 68 parts (original order)
|
||||||
|
* → still the `arkhangel-sk-lhbk` key
|
||||||
|
* RU-PEO "Pechora" — the band's SW Voronoi cell (13 parts)
|
||||||
|
* RU-YAM "Yamal" — the band's NE Voronoi cell (15 parts)
|
||||||
|
*
|
||||||
|
* Deterministic, turf-free threshold spec (all data-derived — no magic
|
||||||
|
* coordinates; the same planar model the tests/adjacency build use):
|
||||||
|
* 1. centroid of a part = the mean of its outer-ring vertices (verified to
|
||||||
|
* give the same band assignment as turf's area-weighted centroid, 0 flips).
|
||||||
|
* 2. band = the parts whose centroid lat ≥ 68 (asserted to be exactly 28).
|
||||||
|
* 3. anchors = the 2 band parts with the largest planar area (≈ 47,223 and
|
||||||
|
* ≈ 32,907 km², indices 2 and 1 in the 33-part feature); anchorW = the
|
||||||
|
* anchor with the smaller centroid longitude.
|
||||||
|
* 4. each non-anchor band part → the nearer anchor by km-weighted centroid
|
||||||
|
* distance, `dist(a,b) = hypot(Δlon·111.32·cos(midLat), Δlat·110.574)`;
|
||||||
|
* a tie → west (anchorW). Each anchor → its own group. This yields
|
||||||
|
* PEO = {1,4,5,9,11,12,13,14,16,17,18,20,22} and
|
||||||
|
* YAM = {2,6,15,19,21,23,24,25,26,27,28,29,30,31,32} (0-based indices).
|
||||||
|
*
|
||||||
|
* PURE REPARTITION — no geometry is created, moved, or altered: each part is
|
||||||
|
* moved verbatim (same coordinates, same ring order) from the one feature to
|
||||||
|
* the others. The union of the three output geometries is byte-identical to
|
||||||
|
* the input feature's parts, so no border moves and no new overlap is
|
||||||
|
* possible (the same guarantee as #31). `RU-ARK` keeps its stable feature id,
|
||||||
|
* so its region key (`arkhangel-sk-lhbk`, FNV-1a of the id) and every existing
|
||||||
|
* `Nation.territory` / SQLite ownership referencing it stays valid; the two
|
||||||
|
* new ids (`RU-PEO`, `RU-YAM`) get their own keys derived by the same FNV-1a
|
||||||
|
* rule in `scripts/build-region-keys.mjs` (`pechora-qwhs`, `yamal-gosw` —
|
||||||
|
* run it after this stage).
|
||||||
|
*
|
||||||
|
* The new features are inserted immediately after `RU-ARK-SZ` (stable file
|
||||||
|
* order, deterministic bytes: `RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM,
|
||||||
|
* …`). Each new feature's `properties` copies `RU-ARK`'s (only `name` and
|
||||||
|
* `parent` exist in this dataset) with `name` overridden; `parent` stays RUS.
|
||||||
|
* Each feature's geometry is a MultiPolygon of its assigned parts (in
|
||||||
|
* original order), or a single Polygon if it ended up with one part (it won't
|
||||||
|
* here, but the builder handles it).
|
||||||
|
*
|
||||||
|
* Pipeline position: AFTER `split-arkhangel` and BEFORE `build-region-keys`
|
||||||
|
* (the manifests must be rebuilt after any geo change):
|
||||||
|
*
|
||||||
|
* generate → coarsen → cleanup → smooth → fill-gaps → split-arkhangel
|
||||||
|
* → **split-arkhangel-band** → build-region-keys → build-region-adjacency
|
||||||
|
*
|
||||||
|
* Idempotent: when RU-PEO and RU-YAM already exist the file is left untouched
|
||||||
|
* (no write at all), so a full re-run of the pipeline reproduces byte-identical
|
||||||
|
* output.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/split-arkhangel-band.mjs [inFile [outFile]]
|
||||||
|
* (or `npm run split:regions:band`)
|
||||||
|
* inFile default: public/regions.geo.json
|
||||||
|
* outFile default: inFile (edit in place)
|
||||||
|
*
|
||||||
|
* Deliberately turf-free: no `@turf/*` import (turf is only ever imported by
|
||||||
|
* the geo-generator `.mjs` stages, never by this re-partition or by any `.ts`).
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
const inFile = process.argv[2] ?? path.join(root, 'public', 'regions.geo.json');
|
||||||
|
const outFile = process.argv[3] ?? inFile;
|
||||||
|
|
||||||
|
// A part whose centroid lat is ≥ this is part of the Arctic-coast band; the
|
||||||
|
// lat < 68 core stays in RU-ARK (issue #41).
|
||||||
|
const BAND_MIN_LAT = 68;
|
||||||
|
|
||||||
|
// The post-#31 mainland feature is a 33-part MultiPolygon; any other shape
|
||||||
|
// means the file is not the state this stage expects → fail loud.
|
||||||
|
const EXPECTED_PARTS = 33;
|
||||||
|
// The Arctic-coast band is exactly 28 of those parts (lat ≥ 68).
|
||||||
|
const EXPECTED_BAND = 28;
|
||||||
|
|
||||||
|
// Planar km model (matches the test's area model and the adjacency build):
|
||||||
|
// x = lon · 111.32 · cos(midLat), y = lat · 110.574.
|
||||||
|
const KM_LON = 111.32;
|
||||||
|
const KM_LAT = 110.574;
|
||||||
|
const rad = (deg) => (deg * Math.PI) / 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mean of a part's outer-ring vertices — a turf-free centroid. Verified to give
|
||||||
|
* the same band assignment as turf's area-weighted centroid (0 flips).
|
||||||
|
*/
|
||||||
|
function centroidOf(part) {
|
||||||
|
const ring = part[0];
|
||||||
|
let lon = 0;
|
||||||
|
let lat = 0;
|
||||||
|
for (const [lo, la] of ring) {
|
||||||
|
lon += lo;
|
||||||
|
lat += la;
|
||||||
|
}
|
||||||
|
return [lon / ring.length, lat / ring.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Planar area of a part (km²), shoelace over its outer ring at its mean latitude. */
|
||||||
|
function areaKm2(part) {
|
||||||
|
const ring = part[0];
|
||||||
|
let lat = 0;
|
||||||
|
for (const [, la] of ring) lat += la;
|
||||||
|
const midLat = lat / ring.length;
|
||||||
|
const kx = KM_LON * Math.cos(rad(midLat));
|
||||||
|
let a = 0;
|
||||||
|
for (let i = 0; i < ring.length; i++) {
|
||||||
|
const [lon1, lat1] = ring[i];
|
||||||
|
const [lon2, lat2] = ring[(i + 1) % ring.length];
|
||||||
|
a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT);
|
||||||
|
}
|
||||||
|
return Math.abs(a / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Planar km distance between two centroids (midpoint-latitude weighted). */
|
||||||
|
function kmDist(a, b) {
|
||||||
|
const dLon = b[0] - a[0];
|
||||||
|
const dLat = b[1] - a[1];
|
||||||
|
const midLat = (a[1] + b[1]) / 2;
|
||||||
|
return Math.hypot(dLon * KM_LON * Math.cos(rad(midLat)), dLat * KM_LAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load --------------------------------------------------------------------
|
||||||
|
const geo = JSON.parse(readFileSync(inFile, 'utf8'));
|
||||||
|
if (geo?.type !== 'FeatureCollection' || !Array.isArray(geo.features)) {
|
||||||
|
throw new Error(`${inFile} is not a FeatureCollection`);
|
||||||
|
}
|
||||||
|
const features = geo.features;
|
||||||
|
|
||||||
|
// --- idempotency: already split? ----------------------------------------------
|
||||||
|
const ids = new Set(features.map((f) => String(f.id ?? '')));
|
||||||
|
if (ids.has('RU-PEO') && ids.has('RU-YAM')) {
|
||||||
|
console.log(
|
||||||
|
`[split-arkhangel-band] RU-PEO and RU-YAM already present — no-op (idempotent), ${inFile} left untouched`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load + validate RU-ARK ---------------------------------------------------
|
||||||
|
const idx = features.findIndex((f) => f.id === 'RU-ARK');
|
||||||
|
if (idx < 0) throw new Error('RU-ARK not found in ' + inFile);
|
||||||
|
const ark = features[idx];
|
||||||
|
if (ark.geometry?.type !== 'MultiPolygon') {
|
||||||
|
throw new Error(
|
||||||
|
`RU-ARK geometry is ${ark.geometry?.type}, expected MultiPolygon (the 33-part post-#31 mainland)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parts = ark.geometry.coordinates;
|
||||||
|
if (parts.length !== EXPECTED_PARTS) {
|
||||||
|
throw new Error(`RU-ARK has ${parts.length} parts, expected ${EXPECTED_PARTS} (the post-#31 mainland)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- band selection (centroid lat ≥ 68) and the lat < 68 core -----------------
|
||||||
|
const partsByIndex = parts.map((p, i) => ({ i, c: centroidOf(p) }));
|
||||||
|
const band = partsByIndex.filter((o) => o.c[1] >= BAND_MIN_LAT);
|
||||||
|
if (band.length !== EXPECTED_BAND) {
|
||||||
|
throw new Error(`expected exactly ${EXPECTED_BAND} Arctic-coast band parts (lat ≥ ${BAND_MIN_LAT}), found ${band.length}`);
|
||||||
|
}
|
||||||
|
const core = partsByIndex.filter((o) => o.c[1] < BAND_MIN_LAT);
|
||||||
|
|
||||||
|
// --- anchors: the two band parts with the largest planar area -----------------
|
||||||
|
// (≈ 47,223 and ≈ 32,907 km², indices 2 and 1 in the 33-part feature).
|
||||||
|
const byArea = [...band].sort((a, b) => areaKm2(parts[b.i]) - areaKm2(parts[a.i]));
|
||||||
|
const big = byArea[0];
|
||||||
|
const small = byArea[1];
|
||||||
|
const anchorW = big.c[0] <= small.c[0] ? big : small; // the west (SW) anchor
|
||||||
|
const anchorE = big.c[0] <= small.c[0] ? small : big; // the east (NE) anchor
|
||||||
|
|
||||||
|
// --- assignment (Voronoi by km-weighted centroid distance; tie → west) --------
|
||||||
|
const peo = new Set([anchorW.i]);
|
||||||
|
const yam = new Set([anchorE.i]);
|
||||||
|
for (const o of band) {
|
||||||
|
if (o.i === anchorW.i || o.i === anchorE.i) continue;
|
||||||
|
const dW = kmDist(o.c, anchorW.c);
|
||||||
|
const dE = kmDist(o.c, anchorE.c);
|
||||||
|
(dW <= dE ? peo : yam).add(o.i); // a tie goes to the west (anchorW) group
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rebuild geometries (parts verbatim, original order; MultiPolygon/Polygon) --
|
||||||
|
const geom = (ps) =>
|
||||||
|
ps.length > 1 ? { type: 'MultiPolygon', coordinates: ps } : { type: 'Polygon', coordinates: ps[0] };
|
||||||
|
|
||||||
|
const coreParts = [...core].sort((a, b) => a.i - b.i).map((o) => parts[o.i]);
|
||||||
|
const peoParts = [...peo].sort((a, b) => a - b).map((i) => parts[i]);
|
||||||
|
const yamParts = [...yam].sort((a, b) => a - b).map((i) => parts[i]);
|
||||||
|
|
||||||
|
ark.geometry = geom(coreParts);
|
||||||
|
|
||||||
|
const mkFeature = (id, name, ps) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
id,
|
||||||
|
// copy every property RU-ARK has (currently just { name, parent }), overriding name
|
||||||
|
properties: { ...ark.properties, name },
|
||||||
|
geometry: geom(ps),
|
||||||
|
});
|
||||||
|
const peoF = mkFeature('RU-PEO', 'Pechora', peoParts);
|
||||||
|
const yamF = mkFeature('RU-YAM', 'Yamal', yamParts);
|
||||||
|
|
||||||
|
// --- report -------------------------------------------------------------------
|
||||||
|
const km2 = (ps) => ps.reduce((s, p) => s + areaKm2(p), 0);
|
||||||
|
console.log(`[split-arkhangel-band] input: ${inFile}`);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK (core, lat < 68) ${coreParts.length} parts (of ${parts.length}), ≈ ${Math.round(km2(coreParts)).toLocaleString('en-US')} km²`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-PEO (Pechora, SW) ${peoParts.length} parts, ≈ ${Math.round(km2(peoParts)).toLocaleString('en-US')} km² [${[...peo].sort((a, b) => a - b).join(',')}]`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-YAM (Yamal, NE) ${yamParts.length} parts, ≈ ${Math.round(km2(yamParts)).toLocaleString('en-US')} km² [${[...yam].sort((a, b) => a - b).join(',')}]`,
|
||||||
|
);
|
||||||
|
if (peoParts.length === 0 || yamParts.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`threshold mis-split: Pechora=${peoParts.length} parts, Yamal=${yamParts.length} parts — check the band before shipping`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- insert at a stable position (immediately after RU-ARK-SZ) and write -------
|
||||||
|
// RU-ARK-SZ must exist (the #31 split ran first); if it doesn't, this stage is
|
||||||
|
// being run out of order → fail loud.
|
||||||
|
const szIdx = features.findIndex((f) => f.id === 'RU-ARK-SZ');
|
||||||
|
if (szIdx < 0) {
|
||||||
|
throw new Error('RU-ARK-SZ not found — the #31 split (scripts/split-arkhangel.mjs) must run before this stage');
|
||||||
|
}
|
||||||
|
features.splice(szIdx + 1, 0, peoF, yamF);
|
||||||
|
writeFileSync(outFile, JSON.stringify(geo));
|
||||||
|
console.log(
|
||||||
|
`[split-arkhangel-band] wrote ${outFile} — features ${features.length - 2} → ${features.length}` +
|
||||||
|
` (order …, RU-ARK, RU-ARK-NE, RU-ARK-SZ, RU-PEO, RU-YAM, …)`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/split-arkhangel.mjs — split the RU-ARK ("Arkhangel'sk") mega-region
|
||||||
|
* into main + two detached island archipelagos (issue #31).
|
||||||
|
*
|
||||||
|
* `RU-ARK` is a 78-part MultiPolygon of ~411,500 km²: the Arkhangel'sk
|
||||||
|
* mainland plus Arctic-coast mainland parts, tiny islets, AND two far-north
|
||||||
|
* island groups that belong nowhere near the mainland — the Franz Josef Land
|
||||||
|
* islets (New Earth, centroid lat ≥ 79.5) and the Severnaya Zemlya islets
|
||||||
|
* (centroid lat 76–79.5, lon ≥ 55). This stage repartitions the existing
|
||||||
|
* polygon parts between three features so each island group is its own
|
||||||
|
* addressable sub-region:
|
||||||
|
*
|
||||||
|
* RU-ARK (keeps its id and name) — every part whose centroid has
|
||||||
|
* lat < 76 (the mainland, Arctic-coast
|
||||||
|
* mainland parts, islets, one south dot)
|
||||||
|
* RU-ARK-NE "New Earth" — every part whose centroid has
|
||||||
|
* lat ≥ 79.5 (the 42 Franz Josef Land
|
||||||
|
* islets, ~15,700 km²)
|
||||||
|
* RU-ARK-SZ "Severnaya Zemlya" — every part whose centroid has
|
||||||
|
* 76 ≤ lat < 79.5 AND lon ≥ 55 (the 3
|
||||||
|
* Severnaya Zemlya islets, ~1,200 km²)
|
||||||
|
*
|
||||||
|
* (Parts in the 76–79.5° band with lon < 55 would stay in RU-ARK; none exist
|
||||||
|
* in the current dataset — the split is verified clean before it is written.)
|
||||||
|
*
|
||||||
|
* PURE REPARTITION — no geometry is created, moved, or altered: each part is
|
||||||
|
* moved verbatim (same coordinates, same ring order) from the one feature to
|
||||||
|
* the other. The union of the three output geometries is byte-identical to
|
||||||
|
* the input feature's parts, so no border moves and no new overlap is
|
||||||
|
* possible. RU-ARK keeps its stable feature id, so its region key
|
||||||
|
* (`arkhangel-sk-lhbk`, FNV-1a of the id) and every existing
|
||||||
|
* `Nation.territory` / SQLite ownership referencing it stays valid; the two
|
||||||
|
* new ids (`RU-ARK-NE`, `RU-ARK-SZ`) get their own keys derived by the same
|
||||||
|
* FNV-1a rule in `scripts/build-region-keys.mjs` (run it after this stage).
|
||||||
|
*
|
||||||
|
* The new features are inserted immediately after RU-ARK (stable file order,
|
||||||
|
* deterministic bytes). Each new feature's `properties` copies RU-ARK's
|
||||||
|
* (only `name` and `parent` exist in this dataset) with `name` overridden;
|
||||||
|
* `parent` stays RUS. Each feature's geometry is a MultiPolygon of its
|
||||||
|
* assigned parts, or a single Polygon if it ends up with one part.
|
||||||
|
*
|
||||||
|
* Pipeline position: between `fill-gaps` and `build-region-keys` (the
|
||||||
|
* manifests must be rebuilt after any geo change):
|
||||||
|
*
|
||||||
|
* generate → coarsen → cleanup → smooth → fill-gaps → **split-arkhangel**
|
||||||
|
* → build-region-keys → build-region-adjacency
|
||||||
|
*
|
||||||
|
* Idempotent: when RU-ARK-NE and RU-ARK-SZ already exist the file is left
|
||||||
|
* untouched (no write at all), so a full re-run of the pipeline reproduces
|
||||||
|
* byte-identical output.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/split-arkhangel.mjs [inFile [outFile]]
|
||||||
|
* (or `npm run split:regions`)
|
||||||
|
* inFile default: public/regions.geo.json
|
||||||
|
* outFile default: inFile (edit in place)
|
||||||
|
*
|
||||||
|
* `@turf/centroid` + `@turf/helpers` are used only to classify each part by
|
||||||
|
* its centroid — the same measurement the split thresholds are defined in.
|
||||||
|
* (Turf is allowed in .mjs scripts only; never imported by any .ts file.)
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import centroid from '@turf/centroid';
|
||||||
|
import area from '@turf/area';
|
||||||
|
import { polygon } from '@turf/helpers';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.resolve(here, '..');
|
||||||
|
const inFile = process.argv[2] ?? path.join(root, 'public', 'regions.geo.json');
|
||||||
|
const outFile = process.argv[3] ?? inFile;
|
||||||
|
|
||||||
|
// The split thresholds (degrees), per issue #31.
|
||||||
|
const NEW_EARTH_MIN_LAT = 79.5; // centroid lat ≥ this → New Earth (Franz Josef Land)
|
||||||
|
const SZ_BAND_MIN_LAT = 76; // centroid lat ≥ this (and < NEW_EARTH_MIN_LAT)…
|
||||||
|
const SZ_MIN_LON = 55; // …and centroid lon ≥ this → Severnaya Zemlya
|
||||||
|
// (anything else — including the 76–79.5° band west of 55°E, of which the
|
||||||
|
// current dataset has none — stays in the main Arkhangel'sk feature)
|
||||||
|
|
||||||
|
/** A GeoJSON polygon part as a turf Feature (for centroid/area only). */
|
||||||
|
const partFeature = (part) => polygon(part);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A part's centroid [lon, lat]. Strict by design: a centroid that cannot be
|
||||||
|
* computed is a data error, not something to silently route to the mainland.
|
||||||
|
*/
|
||||||
|
function centroidOf(part) {
|
||||||
|
try {
|
||||||
|
return centroid(partFeature(part)).geometry.coordinates;
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`cannot compute centroid for a RU-ARK part: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classify a centroid [lon, lat] into one of the three output buckets. */
|
||||||
|
function bucketOf([lon, lat]) {
|
||||||
|
if (lat >= NEW_EARTH_MIN_LAT) return 'ne';
|
||||||
|
if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon >= SZ_MIN_LON) return 'sz';
|
||||||
|
return 'main';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- load --------------------------------------------------------------------
|
||||||
|
const geo = JSON.parse(readFileSync(inFile, 'utf8'));
|
||||||
|
if (geo?.type !== 'FeatureCollection' || !Array.isArray(geo.features)) {
|
||||||
|
throw new Error(`${inFile} is not a FeatureCollection`);
|
||||||
|
}
|
||||||
|
const features = geo.features;
|
||||||
|
|
||||||
|
// --- idempotency: already split? ----------------------------------------------
|
||||||
|
const ids = new Set(features.map((f) => String(f.id ?? '')));
|
||||||
|
if (ids.has('RU-ARK-NE') && ids.has('RU-ARK-SZ')) {
|
||||||
|
console.log(
|
||||||
|
`[split-arkhangel] RU-ARK-NE and RU-ARK-SZ already present — no-op (idempotent), ${inFile} left untouched`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = features.findIndex((f) => f.id === 'RU-ARK');
|
||||||
|
if (idx < 0) {
|
||||||
|
throw new Error('RU-ARK not found in ' + inFile);
|
||||||
|
}
|
||||||
|
const ark = features[idx];
|
||||||
|
if (ark.geometry?.type !== 'MultiPolygon') {
|
||||||
|
throw new Error(
|
||||||
|
`RU-ARK geometry is ${ark.geometry?.type}, expected MultiPolygon (the split expects the pre-#31 mega-region)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parts = ark.geometry.coordinates;
|
||||||
|
|
||||||
|
// --- partition (stable: original part order is kept inside each bucket) -------
|
||||||
|
const buckets = { main: [], ne: [], sz: [] };
|
||||||
|
let bandWest = 0; // 76–79.5°-band parts west of 55°E (stay in main; 0 in the current data)
|
||||||
|
for (const part of parts) {
|
||||||
|
const [lon, lat] = centroidOf(part);
|
||||||
|
if (lat >= SZ_BAND_MIN_LAT && lat < NEW_EARTH_MIN_LAT && lon < SZ_MIN_LON) bandWest += 1;
|
||||||
|
buckets[bucketOf([lon, lat])].push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rebuild geometries (parts verbatim; MultiPolygon, or Polygon if 1 part) --
|
||||||
|
const geom = (ps) =>
|
||||||
|
ps.length > 1
|
||||||
|
? { type: 'MultiPolygon', coordinates: ps }
|
||||||
|
: { type: 'Polygon', coordinates: ps[0] };
|
||||||
|
|
||||||
|
ark.geometry = geom(buckets.main);
|
||||||
|
|
||||||
|
const mkFeature = (id, name, ps) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
id,
|
||||||
|
// copy every property RU-ARK has (currently just { name, parent }), overriding name
|
||||||
|
properties: { ...ark.properties, name },
|
||||||
|
geometry: geom(ps),
|
||||||
|
});
|
||||||
|
const ne = mkFeature('RU-ARK-NE', 'New Earth', buckets.ne);
|
||||||
|
const sz = mkFeature('RU-ARK-SZ', 'Severnaya Zemlya', buckets.sz);
|
||||||
|
|
||||||
|
// --- report -------------------------------------------------------------------
|
||||||
|
const km2 = (ps) => ps.reduce((s, p) => s + area(partFeature(p)) * 1e-6, 0);
|
||||||
|
console.log(`[split-arkhangel] input: ${inFile}`);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK (main) ${buckets.main.length} parts (of ${parts.length}), ≈ ${Math.round(km2(buckets.main)).toLocaleString('en-US')} km²` +
|
||||||
|
(bandWest ? ` — incl. ${bandWest} part(s) in the 76–79.5° band west of 55°E (kept in main)` : ''),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK-NE (New Earth) ${buckets.ne.length} parts, ≈ ${Math.round(km2(buckets.ne)).toLocaleString('en-US')} km²`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` RU-ARK-SZ (Severnaya Zemlya) ${buckets.sz.length} parts, ≈ ${Math.round(km2(buckets.sz)).toLocaleString('en-US')} km²`,
|
||||||
|
);
|
||||||
|
if (buckets.ne.length === 0 || buckets.sz.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`threshold mis-split: New Earth=${buckets.ne.length} parts, Severnaya Zemlya=${buckets.sz.length} parts — check the centroids before shipping`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- insert at a stable position (immediately after RU-ARK) and write ---------
|
||||||
|
features.splice(idx + 1, 0, ne, sz);
|
||||||
|
writeFileSync(outFile, JSON.stringify(geo));
|
||||||
|
console.log(`[split-arkhangel] wrote ${outFile} — features ${features.length - 2} → ${features.length}`);
|
||||||
@@ -23,6 +23,9 @@ function makeState(id: string): GameState {
|
|||||||
events: [],
|
events: [],
|
||||||
pending: [],
|
pending: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +76,39 @@ describe('openDb', () => {
|
|||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('(v0.22 #16 P3) a legacy row saved before the unowned field existed loads with it defaulted to []', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const legacy = makeState('legacy');
|
||||||
|
delete (legacy as { unowned?: string[] }).unowned; // a pre-v0.22 save (field absent)
|
||||||
|
db.save(legacy);
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.unowned).toEqual([]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.23 #16 P4a) a legacy row saved before the pendingNationOps field existed loads with it defaulted to []', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const legacy = makeState('legacy');
|
||||||
|
delete (legacy as { pendingNationOps?: unknown[] }).pendingNationOps; // a pre-v0.23 save (field absent)
|
||||||
|
db.save(legacy);
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.pendingNationOps).toEqual([]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.27 #44) a legacy row saved before the markers field existed loads with it defaulted to []', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const legacy = makeState('legacy');
|
||||||
|
delete (legacy as { markers?: unknown[] }).markers; // a pre-v0.27 save (field absent)
|
||||||
|
db.save(legacy);
|
||||||
|
const loaded = db.load('legacy');
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.markers).toEqual([]);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
it('delete removes a game by id and reports absence otherwise', () => {
|
it('delete removes a game by id and reports absence otherwise', () => {
|
||||||
const db = openDb(':memory:');
|
const db = openDb(':memory:');
|
||||||
db.save(makeState('a'));
|
db.save(makeState('a'));
|
||||||
|
|||||||
+10
-6
@@ -3,7 +3,6 @@ import { mkdirSync } from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
import { migrateTerritoryToRegions } from './regions';
|
|
||||||
|
|
||||||
export interface Db {
|
export interface Db {
|
||||||
save(state: GameState): void;
|
save(state: GameState): void;
|
||||||
@@ -40,11 +39,16 @@ export function openDb(filePath: string): Db {
|
|||||||
const row = byId.get(id) as { data: string } | undefined;
|
const row = byId.get(id) as { data: string } | undefined;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
try {
|
try {
|
||||||
// v0.19: migrate any pre-v0.19 (whole-country ISO_A3) territory to
|
const state = JSON.parse(row.data) as GameState;
|
||||||
// sub-region ids on read. A no-op when the region geo is not loaded,
|
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
|
||||||
// and idempotent once a state has been migrated (the next save then
|
// with it defaulted: a legacy game has no no-man's-land regions.
|
||||||
// persists the region-level territory).
|
if (state.unowned === undefined) state.unowned = [];
|
||||||
return migrateTerritoryToRegions(JSON.parse(row.data) as GameState);
|
// (v0.23, #16 P4a) Same for pending nation-op proposals: a legacy game
|
||||||
|
// has none awaiting confirm/reject.
|
||||||
|
if (state.pendingNationOps === undefined) state.pendingNationOps = [];
|
||||||
|
// (v0.27, #44) Same for map markers: a legacy game has none placed.
|
||||||
|
if (state.markers === undefined) state.markers = [];
|
||||||
|
return state;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+1429
-85
File diff suppressed because it is too large
Load Diff
+818
-46
File diff suppressed because it is too large
Load Diff
+70
-16
@@ -12,15 +12,17 @@ import { deepClone } from '../shared/protocol';
|
|||||||
import type {
|
import type {
|
||||||
AddActionRequest,
|
AddActionRequest,
|
||||||
AddDiplomacyRequest,
|
AddDiplomacyRequest,
|
||||||
|
ConfirmNationOpRequest,
|
||||||
CreateGameRequest,
|
CreateGameRequest,
|
||||||
|
RejectNationOpRequest,
|
||||||
RemoveQueueItemRequest,
|
RemoveQueueItemRequest,
|
||||||
} from '../shared/protocol';
|
} from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
import { createLlmClient } from './llm';
|
import { createLlmClient } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
|
||||||
import { expandTerritory, loadRegions } from './regions';
|
|
||||||
import { buildRegionMap } from './map';
|
import { buildRegionMap } from './map';
|
||||||
|
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, hasAdjacency, hasCentroids, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
|
||||||
import { createPresetStore } from './presets';
|
import { createPresetStore } from './presets';
|
||||||
import { openDb, type Db } from './db';
|
import { openDb, type Db } from './db';
|
||||||
|
|
||||||
@@ -42,10 +44,6 @@ function findRoot(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = findRoot();
|
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 presetsDir = path.join(root, 'presets');
|
||||||
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
|
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
|
||||||
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
||||||
@@ -54,6 +52,9 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
|||||||
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
||||||
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
||||||
const presetStore = createPresetStore(presetsDir);
|
const presetStore = createPresetStore(presetsDir);
|
||||||
|
// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file
|
||||||
|
// just means region expansion/reference degrade to the raw keys (server still boots).
|
||||||
|
loadRegions(root);
|
||||||
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
||||||
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||||||
const useStub = process.env.LLM_STUB === '1';
|
const useStub = process.env.LLM_STUB === '1';
|
||||||
@@ -121,20 +122,25 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
playerNationId: nation.id,
|
playerNationId: nation.id,
|
||||||
turn: 1,
|
turn: 1,
|
||||||
date: preset.startDate,
|
date: preset.startDate,
|
||||||
// v0.19: territory is at sub-region granularity — expand each preset
|
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
|
||||||
// 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: [],
|
events: [],
|
||||||
pending: [],
|
pending: [],
|
||||||
version: 1, // the first saved revision; every later mutation bumps it
|
version: 1, // the first saved revision; every later mutation bumps it
|
||||||
|
// (v0.22, #16 P3) No man's land can only arise mid-game — a fresh game
|
||||||
|
// starts with nothing released; this list grows as the LLM releases
|
||||||
|
// regions and shrinks as they get re-claimed.
|
||||||
|
unowned: [],
|
||||||
|
// (v0.23, #16 P4a) No pending nation-op proposals on a fresh game; the
|
||||||
|
// list grows as the LLM proposes renames (and, later, splits/merges) and
|
||||||
|
// shrinks as the player confirms or rejects them.
|
||||||
|
pendingNationOps: [],
|
||||||
|
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
|
||||||
|
markers: [],
|
||||||
};
|
};
|
||||||
|
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||||||
|
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||||||
|
// granularity from the very first turn. Preset JSONs stay country-level.
|
||||||
|
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
|
||||||
db.save(state);
|
db.save(state);
|
||||||
res.json({ game: state });
|
res.json({ game: state });
|
||||||
});
|
});
|
||||||
@@ -226,6 +232,38 @@ app.post('/api/games/:id/queue/remove', (req, res) => {
|
|||||||
res.json({ game: next });
|
res.json({ game: next });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// (v0.23, #16 P4a; v0.24, #16 P4b) Resolve a pending nation-op proposal
|
||||||
|
// waiting in game.pendingNationOps: a rename takes effect (body.edits.name
|
||||||
|
// may override the proposed name), a split partitions the nation's territory
|
||||||
|
// into the proposed parts (body.edits.playerPart picks which part the player
|
||||||
|
// leads, default 0), and a merge folds its inputs into the survivor
|
||||||
|
// (body.edits.name may override the merged name). No LLM call, no turn
|
||||||
|
// advance — same fast save → broadcast → respond order as /queue/remove.
|
||||||
|
// The version bump lets "never go backwards" clients reconcile.
|
||||||
|
app.post('/api/games/:id/nation-op/confirm', (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const game = db.load(id);
|
||||||
|
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||||
|
const body = (req.body ?? {}) as ConfirmNationOpRequest;
|
||||||
|
const next = confirmNationOp(game, body.index, body.edits);
|
||||||
|
if (!next) return res.status(400).json({ error: 'invalid proposal index' });
|
||||||
|
db.save(next);
|
||||||
|
broadcast(id, 'state', { state: next });
|
||||||
|
res.json({ game: next });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/games/:id/nation-op/reject', (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const game = db.load(id);
|
||||||
|
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||||
|
const body = (req.body ?? {}) as RejectNationOpRequest;
|
||||||
|
const next = rejectNationOp(game, body.index);
|
||||||
|
if (!next) return res.status(400).json({ error: 'invalid proposal index' });
|
||||||
|
db.save(next);
|
||||||
|
broadcast(id, 'state', { state: next });
|
||||||
|
res.json({ game: next });
|
||||||
|
});
|
||||||
|
|
||||||
// Jump forward: commit the whole pending round (or an empty one) to the model
|
// Jump forward: commit the whole pending round (or an empty one) to the model
|
||||||
// in a single turn. applyTurnResult clears the pending queue on the returned
|
// in a single turn. applyTurnResult clears the pending queue on the returned
|
||||||
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
|
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
|
||||||
@@ -241,6 +279,22 @@ app.post('/api/games/:id/jump', async (req, res) => {
|
|||||||
preset,
|
preset,
|
||||||
state: game,
|
state: game,
|
||||||
items: game.pending,
|
items: game.pending,
|
||||||
|
// (v0.22, #16 P3) the region reference table must also cover released
|
||||||
|
// (no-man's-land) keys — they belong to no nation's territory, but the
|
||||||
|
// model needs their names to re-claim them.
|
||||||
|
regionReference: referenceForKeys(
|
||||||
|
new Set([...allTerritoryKeys(game), ...(game.unowned ?? [])]),
|
||||||
|
),
|
||||||
|
validRegionKeys: validKeys(),
|
||||||
|
// v0.21 (#16 P2): when the region-adjacency manifest loaded, hand the
|
||||||
|
// engine the resolver — the turn then runs with the LLM's getNeighbours
|
||||||
|
// tool and the player-frontier prompt field (both switch off without it).
|
||||||
|
getNeighbours: hasAdjacency() ? (keys) => neighboursOf(keys) : undefined,
|
||||||
|
// (v0.27, #44) when the centroid manifest loaded, hand the engine the
|
||||||
|
// resolver so a turn can place new map markers at their region's
|
||||||
|
// centroid (without it, marker adds are dropped; existing markers are
|
||||||
|
// still maintained).
|
||||||
|
centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined,
|
||||||
});
|
});
|
||||||
db.save(state);
|
db.save(state);
|
||||||
broadcast(id, 'turn', { state, result });
|
broadcast(id, 'turn', { state, result });
|
||||||
|
|||||||
@@ -208,3 +208,182 @@ describe('createLlmClient (injectable transport)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createLlmClient.chatWithTools (function-calling loop, v0.21 #16 P2)', () => {
|
||||||
|
const tool = {
|
||||||
|
type: 'function' as const,
|
||||||
|
function: {
|
||||||
|
name: 'getNeighbours',
|
||||||
|
description: 'list bordering region keys',
|
||||||
|
parameters: { type: 'object' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function toolCallResponse(id: string, name: string, args: string) {
|
||||||
|
return json({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
finish_reason: 'tool_calls',
|
||||||
|
message: {
|
||||||
|
content: null,
|
||||||
|
tool_calls: [{ id, type: 'function', function: { name, arguments: args } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentResponse(content: string) {
|
||||||
|
return json({ choices: [{ finish_reason: 'stop', message: { content } }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sends tools in the first request; echoes the assistant tool_calls + tool result; the executor gets parsed args', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}');
|
||||||
|
return contentResponse('final answer');
|
||||||
|
};
|
||||||
|
const executorCalls: Array<{ name: string; args: Record<string, unknown> }> = [];
|
||||||
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
(name, args) => {
|
||||||
|
executorCalls.push({ name, args });
|
||||||
|
return JSON.stringify({ neighbours: ['b'] });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(out).toBe('final answer');
|
||||||
|
expect(sent).toHaveLength(2);
|
||||||
|
// request 1: the tools array is present, history is just the initial message
|
||||||
|
const body1 = JSON.parse(sent[0]);
|
||||||
|
expect(body1.tools).toEqual([tool]);
|
||||||
|
expect(body1.messages).toEqual([{ role: 'user', content: 'hi' }]);
|
||||||
|
// request 2: the assistant tool-call and the tool result are echoed back
|
||||||
|
const body2 = JSON.parse(sent[1]);
|
||||||
|
expect(body2.tools).toEqual([tool]);
|
||||||
|
expect(body2.messages).toEqual([
|
||||||
|
{ role: 'user', content: 'hi' },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: null,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: 'call_1',
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 'getNeighbours', arguments: '{"regionKeys":["a"]}' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ role: 'tool', tool_call_id: 'call_1', content: JSON.stringify({ neighbours: ['b'] }) },
|
||||||
|
]);
|
||||||
|
// the executor received the parsed (object) arguments, not the raw string
|
||||||
|
expect(executorCalls).toEqual([{ name: 'getNeighbours', args: { regionKeys: ['a'] } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a content-only response ends in one round (the executor is never called)', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
return contentResponse('done');
|
||||||
|
};
|
||||||
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
() => {
|
||||||
|
throw new Error('the executor must not be called');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(out).toBe('done');
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds the tool rounds: maxRounds tool-call rounds, then one final no-tools request', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let n = 0;
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
n += 1;
|
||||||
|
if (n <= 2) return toolCallResponse(`call_${n}`, 'getNeighbours', '{}');
|
||||||
|
return contentResponse('committed');
|
||||||
|
};
|
||||||
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
() => JSON.stringify({ neighbours: [] }),
|
||||||
|
{ maxRounds: 2 },
|
||||||
|
);
|
||||||
|
expect(out).toBe('committed');
|
||||||
|
expect(sent).toHaveLength(3); // 2 tool rounds + the forced final request
|
||||||
|
expect(JSON.parse(sent[2]).tools).toBeUndefined(); // the final request has no tools
|
||||||
|
});
|
||||||
|
|
||||||
|
it('malformed tool arguments produce an "invalid tool arguments" tool message (no throw)', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let n = 0;
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
n += 1;
|
||||||
|
if (n === 1) return toolCallResponse('call_1', 'getNeighbours', '{not valid json');
|
||||||
|
return contentResponse('recovered');
|
||||||
|
};
|
||||||
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
() => {
|
||||||
|
throw new Error('the executor must not be called on malformed args');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(out).toBe('recovered');
|
||||||
|
const body2 = JSON.parse(sent[1]);
|
||||||
|
const toolMsg = body2.messages.find((m: { role?: string }) => m.role === 'tool');
|
||||||
|
expect(toolMsg).toBeDefined();
|
||||||
|
expect(toolMsg.content).toMatch(/invalid tool arguments/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a tool-call round on transient failure and still resolves the tool (500 → tool_calls → content)', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
let n = 0;
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
n += 1;
|
||||||
|
if (n === 1) return httpError(500);
|
||||||
|
if (n === 2) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}');
|
||||||
|
return contentResponse('ok');
|
||||||
|
};
|
||||||
|
const llm = createLlmClient({
|
||||||
|
transport,
|
||||||
|
apiBase: 'http://x/v1',
|
||||||
|
model: 'm',
|
||||||
|
retryBaseDelayMs: 1,
|
||||||
|
});
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
() => JSON.stringify({ neighbours: [] }),
|
||||||
|
);
|
||||||
|
expect(out).toBe('ok');
|
||||||
|
expect(sent).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tool-call response is never treated as a truncation failure (content null is fine)', async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const transport = async (_url: string, init?: RequestInit) => {
|
||||||
|
sent.push(String(init?.body ?? ''));
|
||||||
|
if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{}');
|
||||||
|
return contentResponse('ok');
|
||||||
|
};
|
||||||
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||||
|
const out = await llm.chatWithTools!(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
[tool],
|
||||||
|
() => JSON.stringify({ neighbours: [] }),
|
||||||
|
);
|
||||||
|
expect(out).toBe('ok');
|
||||||
|
expect(sent).toHaveLength(2); // no retry of the first (successful) round
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+189
-9
@@ -23,12 +23,55 @@
|
|||||||
// to 3 total attempts and 2s→3s backoff (±20% jitter). HTTP 4xx is a
|
// to 3 total attempts and 2s→3s backoff (±20% jitter). HTTP 4xx is a
|
||||||
// deterministic client error and throws immediately, with no retry. Every
|
// deterministic client error and throws immediately, with no retry. Every
|
||||||
// call is logged: one line on success, one on final failure.
|
// call is logged: one line on success, one on final failure.
|
||||||
|
//
|
||||||
|
// Function calling: `chatWithTools()` extends `chat()` with the OpenAI tool
|
||||||
|
// loop. When the model answers with `finish_reason: "tool_calls"`, the client
|
||||||
|
// runs the provided executor for every requested call, appends the assistant
|
||||||
|
// tool-call message plus one `tool` result message per call, and re-sends the
|
||||||
|
// growing history until the model produces a final `content`. The loop is
|
||||||
|
// bounded to DEFAULT_MAX_TOOL_ROUNDS (6) tool rounds; if the budget is spent
|
||||||
|
// without a final answer, one last *no-tools* request forces the model to
|
||||||
|
// commit. The same retry/backoff/reasoning-effort machinery applies per
|
||||||
|
// request, so an injectable transport only has to forward `tools` in the body
|
||||||
|
// and accept `assistant`/`tool` messages in the history. The deterministic
|
||||||
|
// stub answers plain `content` (no tool calls) and therefore bypasses the
|
||||||
|
// loop in a single round.
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: 'system' | 'user' | 'assistant';
|
role: 'system' | 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One OpenAI-style function call the model requests (`finish_reason: "tool_calls"`). */
|
||||||
|
export interface ToolCallSpec {
|
||||||
|
id: string;
|
||||||
|
type: 'function';
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A function the model may call (one OpenAI `tools` entry). */
|
||||||
|
export interface ToolDefinition {
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full message vocabulary a request can carry: plain chat messages, plus
|
||||||
|
* assistant messages that request tool calls and the `tool` results that
|
||||||
|
* answer them (the OpenAI function-calling wire shape).
|
||||||
|
*/
|
||||||
|
export type WireMessage =
|
||||||
|
| ChatMessage
|
||||||
|
| { role: 'assistant'; content: string | null; tool_calls: ToolCallSpec[] }
|
||||||
|
| { role: 'tool'; tool_call_id: string; content: string };
|
||||||
|
|
||||||
|
/** Executes one tool call; the returned string is fed back to the model as the `tool` result. */
|
||||||
|
export type ToolExecutor = (name: string, args: Record<string, unknown>) => string;
|
||||||
|
|
||||||
/** A network call. The default implementation is native fetch. */
|
/** A network call. The default implementation is native fetch. */
|
||||||
export type Transport = (url: string, init?: RequestInit) => Promise<Response>;
|
export type Transport = (url: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
@@ -39,6 +82,18 @@ export interface ChatOptions {
|
|||||||
|
|
||||||
export interface LlmClient {
|
export interface LlmClient {
|
||||||
chat(messages: ChatMessage[], opts?: ChatOptions): Promise<string>;
|
chat(messages: ChatMessage[], opts?: ChatOptions): Promise<string>;
|
||||||
|
/**
|
||||||
|
* The function-calling variant of chat() — see the file header for the loop
|
||||||
|
* semantics (bounded tool rounds, then a forced final answer). Optional on
|
||||||
|
* the interface: minimal test doubles may omit it, and callers (the
|
||||||
|
* engine's runTurn) fall back to chat() when it is absent.
|
||||||
|
*/
|
||||||
|
chatWithTools?(
|
||||||
|
messages: WireMessage[],
|
||||||
|
tools: ToolDefinition[],
|
||||||
|
executeTool: ToolExecutor,
|
||||||
|
opts?: ChatOptions & { maxRounds?: number },
|
||||||
|
): Promise<string>;
|
||||||
listModels(): Promise<string[]>;
|
listModels(): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +118,17 @@ export const DEFAULT_MAX_TOKENS = 4000;
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_REASONING_EFFORT = 'low';
|
export const DEFAULT_REASONING_EFFORT = 'low';
|
||||||
|
|
||||||
/** Total attempts `chat()` makes before giving up (1 initial + 2 retries). */
|
/** Total attempts per request (chat / chatWithTools) before giving up (1 initial + 2 retries). */
|
||||||
export const MAX_ATTEMPTS = 3;
|
export const MAX_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max tool-call rounds (assistant tool_calls → tool results) a chatWithTools
|
||||||
|
* loop may make before a final no-tools request forces the answer. Keeps a
|
||||||
|
* turn bounded even if the model keeps asking for data; a realistic turn uses
|
||||||
|
* 0–2 rounds.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_MAX_TOOL_ROUNDS = 6;
|
||||||
|
|
||||||
/** Base retry backoff: retry 1 waits 2000ms, retry 2 waits 3000ms, each ±20% jitter. */
|
/** Base retry backoff: retry 1 waits 2000ms, retry 2 waits 3000ms, each ±20% jitter. */
|
||||||
const RETRY_BASE_DELAY_MS = 2000;
|
const RETRY_BASE_DELAY_MS = 2000;
|
||||||
|
|
||||||
@@ -154,6 +217,30 @@ function isNetworkError(err: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse the wire `tool_calls` array into validated ToolCallSpecs (malformed entries are dropped). */
|
||||||
|
function parseToolCalls(raw: unknown): ToolCallSpec[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: ToolCallSpec[] = [];
|
||||||
|
for (const tc of raw) {
|
||||||
|
if (!tc || typeof tc !== 'object') continue;
|
||||||
|
const t = tc as Record<string, unknown>;
|
||||||
|
const fn = t.function as Record<string, unknown> | undefined;
|
||||||
|
if (!fn || typeof fn.name !== 'string' || fn.name === '') continue;
|
||||||
|
const argumentsText =
|
||||||
|
typeof fn.arguments === 'string'
|
||||||
|
? fn.arguments
|
||||||
|
: fn.arguments != null
|
||||||
|
? JSON.stringify(fn.arguments)
|
||||||
|
: '{}';
|
||||||
|
out.push({
|
||||||
|
id: typeof t.id === 'string' && t.id !== '' ? t.id : `call_${out.length}`,
|
||||||
|
type: 'function',
|
||||||
|
function: { name: fn.name, arguments: argumentsText },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** One transport attempt, classified as success / retryable / fatal. */
|
/** One transport attempt, classified as success / retryable / fatal. */
|
||||||
interface Attempt {
|
interface Attempt {
|
||||||
status?: number;
|
status?: number;
|
||||||
@@ -161,6 +248,7 @@ interface Attempt {
|
|||||||
retryable: boolean;
|
retryable: boolean;
|
||||||
content?: string;
|
content?: string;
|
||||||
finishReason?: string | null;
|
finishReason?: string | null;
|
||||||
|
toolCalls?: ToolCallSpec[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||||
@@ -179,7 +267,7 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One POST to /chat/completions. Classifies the outcome:
|
* One POST to /chat/completions. Classifies the outcome:
|
||||||
* - success → content + finish_reason
|
* - success → content and/or tool_calls
|
||||||
* - retryable → timeout/abort, HTTP 5xx, network error, null/empty content
|
* - retryable → timeout/abort, HTTP 5xx, network error, null/empty content
|
||||||
* - fatal → HTTP 4xx (deterministic client error) or an unexpected throw
|
* - fatal → HTTP 4xx (deterministic client error) or an unexpected throw
|
||||||
*/
|
*/
|
||||||
@@ -213,12 +301,27 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
choices?: {
|
choices?: {
|
||||||
finish_reason?: string | null;
|
finish_reason?: string | null;
|
||||||
message?: { content?: string; reasoning?: string | null };
|
message?: { content?: string; reasoning?: string | null; tool_calls?: unknown };
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
const choice = body.choices?.[0];
|
||||||
|
const message = choice?.message;
|
||||||
|
// A tool-call round is a SUCCESS even though `content` is null there: the
|
||||||
|
// model is asking for data, not failing (never treat it as truncation).
|
||||||
|
const toolCalls = parseToolCalls(message?.tool_calls);
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
return {
|
||||||
|
status: res.status,
|
||||||
|
retryable: false,
|
||||||
|
content:
|
||||||
|
typeof message?.content === 'string' && message.content !== '' ? message.content : undefined,
|
||||||
|
finishReason: choice?.finish_reason ?? null,
|
||||||
|
toolCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
// The reasoning model can exhaust its output budget on the CoT and return
|
// The reasoning model can exhaust its output budget on the CoT and return
|
||||||
// a null `content` — treat that as a retryable truncation, not a success.
|
// a null `content` — treat that as a retryable truncation, not a success.
|
||||||
const content = body.choices?.[0]?.message?.content;
|
const content = message?.content;
|
||||||
if (typeof content !== 'string' || content === '') {
|
if (typeof content !== 'string' || content === '') {
|
||||||
return {
|
return {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
@@ -230,11 +333,16 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
status: res.status,
|
status: res.status,
|
||||||
retryable: false,
|
retryable: false,
|
||||||
content,
|
content,
|
||||||
finishReason: body.choices?.[0]?.finish_reason ?? null,
|
finishReason: choice?.finish_reason ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
|
/** Build one /chat/completions request body. `tools` is only included when non-empty, so plain chat() keeps its exact legacy shape. */
|
||||||
|
function buildPayload(
|
||||||
|
messages: WireMessage[],
|
||||||
|
tools: ToolDefinition[] | undefined,
|
||||||
|
opts: ChatOptions,
|
||||||
|
): Record<string, unknown> {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
@@ -245,7 +353,16 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
};
|
};
|
||||||
if (opts.temperature != null) payload.temperature = opts.temperature;
|
if (opts.temperature != null) payload.temperature = opts.temperature;
|
||||||
if (sendEffort) payload.reasoning_effort = effort;
|
if (sendEffort) payload.reasoning_effort = effort;
|
||||||
|
if (tools && tools.length > 0) payload.tools = tools;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One request with the standard retry/backoff: up to MAX_ATTEMPTS per
|
||||||
|
* request; resolves with a successful Attempt (content and/or toolCalls) or
|
||||||
|
* throws the last error once the attempts are spent.
|
||||||
|
*/
|
||||||
|
async function requestWithRetry(payload: Record<string, unknown>): Promise<Attempt> {
|
||||||
const totalStart = Date.now();
|
const totalStart = Date.now();
|
||||||
let lastStatus: number | undefined;
|
let lastStatus: number | undefined;
|
||||||
let lastError: Error = new Error('chat failed: no attempt completed');
|
let lastError: Error = new Error('chat failed: no attempt completed');
|
||||||
@@ -255,12 +372,13 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
if (out.status !== undefined) lastStatus = out.status;
|
if (out.status !== undefined) lastStatus = out.status;
|
||||||
if (out.error) lastError = out.error;
|
if (out.error) lastError = out.error;
|
||||||
|
|
||||||
if (out.content !== undefined) {
|
if (out.content !== undefined || out.toolCalls !== undefined) {
|
||||||
console.log('[llm] chat ok', {
|
console.log('[llm] chat ok', {
|
||||||
ms: Date.now() - totalStart,
|
ms: Date.now() - totalStart,
|
||||||
finishReason: out.finishReason ?? null,
|
finishReason: out.finishReason ?? null,
|
||||||
|
toolCalls: out.toolCalls?.length ?? 0,
|
||||||
});
|
});
|
||||||
return out.content;
|
return out;
|
||||||
}
|
}
|
||||||
// Give up on a non-retryable failure, or when the last attempt is spent.
|
// Give up on a non-retryable failure, or when the last attempt is spent.
|
||||||
if (!out.retryable || attempt === MAX_ATTEMPTS) {
|
if (!out.retryable || attempt === MAX_ATTEMPTS) {
|
||||||
@@ -278,6 +396,68 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
|
||||||
|
const out = await requestWithRetry(buildPayload(messages, undefined, opts));
|
||||||
|
return out.content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function-calling loop — see the file header. Bounded: at most
|
||||||
|
* `maxRounds` tool rounds, then one final no-tools request forces an answer.
|
||||||
|
* Throws like chat() only on transport-level exhaustion (the engine maps that
|
||||||
|
* to a 502; bad *content* never throws, it is parsed defensively downstream).
|
||||||
|
*/
|
||||||
|
async function chatWithTools(
|
||||||
|
initial: WireMessage[],
|
||||||
|
tools: ToolDefinition[],
|
||||||
|
executeTool: ToolExecutor,
|
||||||
|
opts: ChatOptions & { maxRounds?: number } = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const maxRounds = Math.max(1, opts.maxRounds ?? DEFAULT_MAX_TOOL_ROUNDS);
|
||||||
|
const messages: WireMessage[] = [...initial];
|
||||||
|
for (let round = 0; round < maxRounds; round++) {
|
||||||
|
const out = await requestWithRetry(buildPayload(messages, tools, opts));
|
||||||
|
// Tool calls win over any partial content: the model asked for data
|
||||||
|
// before it finished.
|
||||||
|
if (out.toolCalls && out.toolCalls.length > 0) {
|
||||||
|
messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: out.content ?? null,
|
||||||
|
tool_calls: out.toolCalls,
|
||||||
|
});
|
||||||
|
for (const tc of out.toolCalls) {
|
||||||
|
let result: string;
|
||||||
|
try {
|
||||||
|
const parsed: unknown =
|
||||||
|
tc.function.arguments !== '' ? JSON.parse(tc.function.arguments) : {};
|
||||||
|
const args =
|
||||||
|
parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
result = executeTool(tc.function.name, args);
|
||||||
|
} catch (err) {
|
||||||
|
// Malformed arguments are not fatal: hand the model an error
|
||||||
|
// result so it can re-call with valid arguments or move on.
|
||||||
|
result = JSON.stringify({
|
||||||
|
error: `invalid tool arguments: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
messages.push({ role: 'tool', tool_call_id: tc.id, content: result });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (out.content !== undefined) return out.content;
|
||||||
|
}
|
||||||
|
// The tool budget is spent without a final answer: one last no-tools
|
||||||
|
// request forces the model to commit.
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: 'You have reached your tool budget. Answer now with the final result only.',
|
||||||
|
});
|
||||||
|
const out = await requestWithRetry(buildPayload(messages, undefined, opts));
|
||||||
|
return out.content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
async function listModels(): Promise<string[]> {
|
async function listModels(): Promise<string[]> {
|
||||||
const res = await transport(`${apiBase}/models`, {
|
const res = await transport(`${apiBase}/models`, {
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
@@ -287,5 +467,5 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
|||||||
return (body.data ?? []).map((m) => m.id);
|
return (body.data ?? []).map((m) => m.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { chat, listModels };
|
return { chat, chatWithTools, listModels };
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-46
@@ -1,10 +1,24 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildRegionMap } from './map';
|
import { buildRegionMap } from './map';
|
||||||
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
import {
|
||||||
|
mapFeatureOwner,
|
||||||
|
nationColor,
|
||||||
|
NO_MANS_LAND_FILL,
|
||||||
|
NO_MANS_LAND_STROKE,
|
||||||
|
OWNED_STROKE,
|
||||||
|
regionFillStyle,
|
||||||
|
regionOwner,
|
||||||
|
UNOWNED_FILL,
|
||||||
|
UNOWNED_STROKE,
|
||||||
|
unownedTooltipLabel,
|
||||||
|
type RegionMap,
|
||||||
|
} from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
|
// Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed
|
||||||
// not whole-country ISO_A3 codes.
|
// by region key (one per owned sub-region), not by a parent-country ISO_A3. The
|
||||||
|
// keys below are deliberately fake (obviously not real manifest entries) so this
|
||||||
|
// test stays self-contained and format-agnostic.
|
||||||
const state: GameState = {
|
const state: GameState = {
|
||||||
id: 'g',
|
id: 'g',
|
||||||
presetId: 'p',
|
presetId: 'p',
|
||||||
@@ -18,7 +32,7 @@ const state: GameState = {
|
|||||||
name: 'UK',
|
name: 'UK',
|
||||||
type: 'major',
|
type: 'major',
|
||||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||||
territory: ['GB-01', 'GB-02'],
|
territory: ['albania-aaaa', 'wales-bbbb'],
|
||||||
relations: {},
|
relations: {},
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
@@ -26,38 +40,50 @@ const state: GameState = {
|
|||||||
name: 'DE',
|
name: 'DE',
|
||||||
type: 'major',
|
type: 'major',
|
||||||
stats: { military: 90, economy: 70, stability: 70, population: 90 },
|
stats: { military: 90, economy: 70, stability: 70, population: 90 },
|
||||||
territory: ['DE-01'],
|
territory: ['brandenburg-cccc'],
|
||||||
relations: {},
|
relations: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
pending: [],
|
pending: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// The RegionMap built from the fixture: the authoritative sub-region id → owner
|
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||||
// source.
|
|
||||||
const regions: RegionMap = buildRegionMap(state);
|
const regions: RegionMap = buildRegionMap(state);
|
||||||
|
|
||||||
describe('buildRegionMap', () => {
|
describe('buildRegionMap', () => {
|
||||||
it('maps each sub-region id to its owner + that nation\'s stable color', () => {
|
it('maps each owned region key to its owner + that nation\'s stable color', () => {
|
||||||
const m = buildRegionMap(state);
|
const m = buildRegionMap(state);
|
||||||
expect(m['GB-01']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
expect(m['albania-aaaa']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||||
expect(m['GB-02']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
expect(m['wales-bbbb']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||||
expect(m['DE-01']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
expect(m['brandenburg-cccc']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
||||||
// a sub-region nobody owns is simply absent
|
// a region nobody owns is simply absent
|
||||||
expect(m['FR-57']).toBeUndefined();
|
expect(m['normandie-dddd']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.22 #16 P3) a released (no-man\'s-land) key — in GameState.unowned, owned by no nation — is absent: absence IS unowned', () => {
|
||||||
|
const s: GameState = JSON.parse(JSON.stringify(state));
|
||||||
|
// simulate a mid-game release: the key left every territory and entered unowned
|
||||||
|
s.nations.uk.territory = s.nations.uk.territory.filter((k) => k !== 'albania-aaaa');
|
||||||
|
s.unowned = ['albania-aaaa'];
|
||||||
|
const m = buildRegionMap(s);
|
||||||
|
expect(m['albania-aaaa']).toBeUndefined(); // the map cannot tell which kind of unowned
|
||||||
|
expect(s.unowned).toEqual(['albania-aaaa']); // ...the client's GameState.unowned decides that
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('regionOwner', () => {
|
describe('regionOwner', () => {
|
||||||
it('resolves an owned sub-region id to its owner record', () => {
|
it('resolves an owned region key to its owner record', () => {
|
||||||
expect(regionOwner(regions, 'GB-01')).toEqual({
|
expect(regionOwner(regions, 'albania-aaaa')).toEqual({
|
||||||
color: nationColor('uk'),
|
color: nationColor('uk'),
|
||||||
owner: 'UK',
|
owner: 'UK',
|
||||||
ownerId: 'uk',
|
ownerId: 'uk',
|
||||||
});
|
});
|
||||||
expect(regionOwner(regions, 'DE-01')).toEqual({
|
expect(regionOwner(regions, 'brandenburg-cccc')).toEqual({
|
||||||
color: nationColor('de'),
|
color: nationColor('de'),
|
||||||
owner: 'DE',
|
owner: 'DE',
|
||||||
ownerId: 'de',
|
ownerId: 'de',
|
||||||
@@ -65,11 +91,11 @@ describe('regionOwner', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
||||||
expect(regionOwner(regions, 'FR-57')).toBeUndefined();
|
expect(regionOwner(regions, 'normandie-dddd')).toBeUndefined();
|
||||||
expect(regionOwner(regions, 'US-01')).toBeUndefined();
|
expect(regionOwner(regions, 'yorkshire-eeee')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for an empty or missing id', () => {
|
it('returns undefined for an empty or missing key', () => {
|
||||||
expect(regionOwner(regions, '')).toBeUndefined();
|
expect(regionOwner(regions, '')).toBeUndefined();
|
||||||
expect(regionOwner(regions, null)).toBeUndefined();
|
expect(regionOwner(regions, null)).toBeUndefined();
|
||||||
expect(regionOwner(regions, undefined)).toBeUndefined();
|
expect(regionOwner(regions, undefined)).toBeUndefined();
|
||||||
@@ -77,28 +103,29 @@ describe('regionOwner', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #6 regression guard (sub-national map, v0.19 region-level ownership). The
|
* #6 regression guard (sub-national map, region-keyed). The click/hover handlers
|
||||||
* click/hover handlers resolve the owning nation from a rendered map feature.
|
* resolve the owning nation from a rendered map feature. MapLibre repacks a
|
||||||
* MapLibre repacks a GeoJSON source's top-level `id` into a *numeric index*
|
* GeoJSON source's top-level `id` into a *numeric index* when it stores the
|
||||||
* when it stores the source, so a feature the map hands back at interaction
|
* source, so a feature the map hands back at interaction time has a number for
|
||||||
* time has a number for `id`. Since v0.19 ownership is at **sub-region**
|
* `id`. The choropleth is sub-national: a feature's own sub-region id is a code
|
||||||
* granularity: a feature's owner lives at its **own sub-region id** (e.g.
|
* (`DEU-03`), and its owner now lives at its **region key** (`brandenburg-cccc`),
|
||||||
* `DE-01`), which the client stamps into `properties.regionId` (the RegionMap
|
* which the client stamps into `properties.regionKey` from `region-keys.json`
|
||||||
* is keyed by that id). This pins the exact data shape and proves the
|
* (the RegionMap is keyed by that key, never the sub-region id nor the repacked
|
||||||
* resolution: `mapFeatureOwner` works via `properties.regionId`, while the
|
* id). This pins the exact data shape and proves the resolution:
|
||||||
* repacked numeric `feature.id` path does not.
|
* `mapFeatureOwner` works via `properties.regionKey`, while the two wrong paths
|
||||||
|
* (the repacked numeric `feature.id`, and the sub-region `regionId`, which is
|
||||||
|
* not a RegionMap key) do not.
|
||||||
*/
|
*/
|
||||||
describe('#6 — region owner must be resolved from properties.regionId, never the repacked feature.id', () => {
|
describe('#6 — sub-region owner must be resolved from properties.regionKey, not the sub-region id / repacked feature.id', () => {
|
||||||
// The shape MapLibre returns for a sub-region feature after setData(): `id`
|
// The shape MapLibre returns for a sub-region feature after setData(): `id`
|
||||||
// is a number (repacked), and the stable identifiers live only in properties
|
// is a number (repacked), and the stable identifiers live only in properties —
|
||||||
// — `regionId` (the sub-region id, the RegionMap key) and `parent` (the
|
// `regionId` (the sub-region code) and `regionKey` (the region key).
|
||||||
// static parent country's ISO_A3).
|
|
||||||
const repackedFeature = {
|
const repackedFeature = {
|
||||||
id: 0,
|
id: 0,
|
||||||
properties: { name: 'Rhineland', regionId: 'DE-01', parent: 'DEU' },
|
properties: { name: 'Brandenburg R03', regionId: 'DEU-03', regionKey: 'brandenburg-cccc' },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('resolves the owner from properties.regionId (the fix)', () => {
|
it('resolves the owner from properties.regionKey (the fix)', () => {
|
||||||
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
||||||
color: nationColor('de'),
|
color: nationColor('de'),
|
||||||
owner: 'DE',
|
owner: 'DE',
|
||||||
@@ -106,30 +133,30 @@ describe('#6 — region owner must be resolved from properties.regionId, never t
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the repacked numeric feature.id does NOT resolve — the original bug', () => {
|
it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
|
||||||
|
// 'DEU-03' is a sub-region id; the RegionMap is keyed by region key.
|
||||||
|
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the repacked numeric feature.id does NOT resolve — the bug', () => {
|
||||||
// String(0) === '0' is not a valid key, so the pre-fix lookup failed and
|
// String(0) === '0' is not a valid key, so the pre-fix lookup failed and
|
||||||
// reported "No owner" even for owned regions.
|
// reported "No owner" even for owned regions.
|
||||||
expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined();
|
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', () => {
|
describe('mapFeatureOwner', () => {
|
||||||
it('returns undefined for a missing feature, missing regionId, or non-string regionId', () => {
|
it('returns undefined for a missing feature, missing regionKey, or non-string regionKey', () => {
|
||||||
expect(mapFeatureOwner(regions, null)).toBeUndefined();
|
expect(mapFeatureOwner(regions, null)).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
|
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, {})).toBeUndefined();
|
expect(mapFeatureOwner(regions, {})).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, { properties: { regionId: 42 } })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 42 } })).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, { properties: { regionId: '' } })).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for a sub-region nobody owns', () => {
|
it('returns undefined for a region key nobody owns', () => {
|
||||||
expect(mapFeatureOwner(regions, { properties: { regionId: 'FR-57' } })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 'normandie-dddd' } })).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, { properties: { regionId: 'US-01' } })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 'yorkshire-eeee' } })).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,3 +168,48 @@ describe('nationColor', () => {
|
|||||||
expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/);
|
expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// (v0.22, #16 P3) The no-man's-land presentation contract lives in
|
||||||
|
// shared/protocol (shared/*.test.ts is not in the vitest glob, so these
|
||||||
|
// tests live here): the distinct visible grey, the faint preset-unassigned
|
||||||
|
// look, the per-kind borders, and the tooltip sub-line.
|
||||||
|
describe('no man\'s land presentation contract (v0.22 #16 P3)', () => {
|
||||||
|
it('regionFillStyle: an owned region uses its nation color + the owned border', () => {
|
||||||
|
expect(regionFillStyle(regions['albania-aaaa'], false)).toEqual({
|
||||||
|
fill: nationColor('uk'),
|
||||||
|
stroke: OWNED_STROKE,
|
||||||
|
});
|
||||||
|
expect(regionFillStyle(regions['brandenburg-cccc'], true)).toEqual({
|
||||||
|
fill: nationColor('de'),
|
||||||
|
stroke: OWNED_STROKE,
|
||||||
|
}); // the no-man's-land flag never affects an owned region
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regionFillStyle: an unowned region that IS no man\'s land gets the distinct visible grey', () => {
|
||||||
|
expect(regionFillStyle(undefined, true)).toEqual({
|
||||||
|
fill: NO_MANS_LAND_FILL,
|
||||||
|
stroke: NO_MANS_LAND_STROKE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regionFillStyle: an unowned region that was never released keeps the faint look', () => {
|
||||||
|
expect(regionFillStyle(undefined, false)).toEqual({
|
||||||
|
fill: UNOWNED_FILL,
|
||||||
|
stroke: UNOWNED_STROKE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the no-man\'s-land grey can never collide with a nation color (hue-less vs HSL)', () => {
|
||||||
|
expect(NO_MANS_LAND_FILL).not.toMatch(/^hsl\(/);
|
||||||
|
expect(NO_MANS_LAND_FILL).not.toBe(nationColor('uk'));
|
||||||
|
expect(NO_MANS_LAND_FILL).not.toBe(nationColor('de'));
|
||||||
|
// and it must be visibly different from the faint preset-unassigned fill
|
||||||
|
expect(NO_MANS_LAND_FILL).not.toBe(UNOWNED_FILL);
|
||||||
|
expect(NO_MANS_LAND_STROKE).not.toBe(UNOWNED_STROKE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unownedTooltipLabel: the tooltip sub-line names the two kinds of unowned', () => {
|
||||||
|
expect(unownedTooltipLabel(true)).toBe("No man's land");
|
||||||
|
expect(unownedTooltipLabel(false)).toBe('No owner');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+14
-9
@@ -1,10 +1,15 @@
|
|||||||
// Build the choropleth payload from a GameState: every **sub-region id** in a
|
// Build the choropleth payload from a GameState: every region key (see
|
||||||
// nation's territory maps to its owning nation plus that nation's stable color
|
// public/region-keys.json) maps to its owning nation plus that nation's stable
|
||||||
// (see shared.nationColor). The client merges this onto public/regions.geo.json
|
// color (see shared.nationColor). The client merges this onto the sub-national
|
||||||
// and calls source.setData(). The map is keyed by whatever ids sit in
|
// regions.geo.json (resolving each feature by its own region key) and calls
|
||||||
// `nation.territory` — sub-region ids (e.g. `FR-57`) as of v0.19 — so it needs
|
// source.setData(). Format-agnostic: it maps whatever keys sit in territory.
|
||||||
// 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).
|
// A key ABSENT from the returned map is unowned (no nation's territory holds
|
||||||
|
// it) — this covers both regions a preset never assigned and regions released
|
||||||
|
// to no man's land mid-game (v0.22, #16 P3). The map deliberately does NOT
|
||||||
|
// distinguish the two: the visible no-man's-land grey vs the faint
|
||||||
|
// preset-unassigned look is decided by the client from `GameState.unowned`
|
||||||
|
// (see shared/protocol.ts's regionFillStyle).
|
||||||
import { nationColor, type RegionMap } from '../shared/protocol';
|
import { nationColor, type RegionMap } from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
@@ -12,8 +17,8 @@ export function buildRegionMap(state: GameState): RegionMap {
|
|||||||
const map: RegionMap = {};
|
const map: RegionMap = {};
|
||||||
for (const [id, nation] of Object.entries(state.nations)) {
|
for (const [id, nation] of Object.entries(state.nations)) {
|
||||||
const color = nationColor(id);
|
const color = nationColor(id);
|
||||||
for (const regionId of nation.territory) {
|
for (const key of nation.territory) {
|
||||||
map[regionId] = { color, owner: nation.name, ownerId: id };
|
map[key] = { color, owner: nation.name, ownerId: id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
+195
-128
@@ -1,34 +1,81 @@
|
|||||||
/**
|
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
* 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 { fileURLToPath } from 'node:url';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
import type { GameState } from '../shared/types';
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
_resetRegionsForTests,
|
allTerritoryKeys,
|
||||||
_setRegionsForTests,
|
centroidOf,
|
||||||
expandTerritory,
|
expandCountriesToKeys,
|
||||||
|
hasAdjacency,
|
||||||
|
hasCentroids,
|
||||||
hasRegionData,
|
hasRegionData,
|
||||||
loadRegions,
|
loadRegions,
|
||||||
migrateTerritoryToRegions,
|
neighboursOf,
|
||||||
regionLabel,
|
referenceForKeys,
|
||||||
regionNamesFor,
|
regionName,
|
||||||
|
resetRegions,
|
||||||
|
validKeys,
|
||||||
} from './regions';
|
} from './regions';
|
||||||
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
// The repo root: this file lives in server/, so '..' is the directory holding
|
||||||
const root = path.resolve(here, '..');
|
// public/region-keys.json and public/regions.geo.json. cwd-independent (mirrors
|
||||||
|
// how server/index.ts locates its root via import.meta.url).
|
||||||
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
|
||||||
function makeState(territory: string[]): GameState {
|
describe('regions (region-key loader, v0.19 #16 P1)', () => {
|
||||||
return {
|
beforeEach(() => {
|
||||||
|
expect(loadRegions(root)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the full manifest: 1,800 unique valid region keys', () => {
|
||||||
|
expect(hasRegionData()).toBe(true);
|
||||||
|
expect(validKeys().size).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands an ISO_A3 country to the region keys of all its sub-regions', () => {
|
||||||
|
const fra = expandCountriesToKeys(['FRA']);
|
||||||
|
// FRA has 42 sub-regions in the committed dataset.
|
||||||
|
expect(fra).toHaveLength(42);
|
||||||
|
// every expanded key is a real region key
|
||||||
|
for (const k of fra) expect(validKeys().has(k)).toBe(true);
|
||||||
|
// and the reference table labels each one with its parent country
|
||||||
|
const ref = referenceForKeys(fra);
|
||||||
|
expect(Object.keys(ref)).toHaveLength(42);
|
||||||
|
for (const [k, label] of Object.entries(ref)) {
|
||||||
|
expect(label.endsWith('(FRA)')).toBe(true);
|
||||||
|
expect(regionName(k)).toBeTruthy();
|
||||||
|
}
|
||||||
|
// sample known FRA keys, and their exact reference strings
|
||||||
|
expect(fra).toContain('ain-lqtt');
|
||||||
|
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
|
||||||
|
expect(ref['aisne-gstu']).toBe('Aisne (FRA)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands RUS to all 84 sub-regions after the #31 + #41 Arkhangel splits (no ownership gap at game init)', () => {
|
||||||
|
// #31 split RU-ARK into the mainland + New Earth + Severnaya Zemlya; #41
|
||||||
|
// repartitioned the mainland into the core (unchanged key) + Pechora +
|
||||||
|
// Yamal. A fresh game's RUS territory (expanded from the preset's
|
||||||
|
// country list) must contain all five sub-region keys — the main one
|
||||||
|
// (ownership-safe, unchanged key) plus the island and band ones.
|
||||||
|
const rus = expandCountriesToKeys(['RUS']);
|
||||||
|
expect(rus).toHaveLength(84);
|
||||||
|
expect(rus).toContain('arkhangel-sk-lhbk');
|
||||||
|
expect(rus).toContain('new-earth-eikt');
|
||||||
|
expect(rus).toContain('severnaya-zemlya-awqp');
|
||||||
|
expect(rus).toContain('pechora-qwhs');
|
||||||
|
expect(rus).toContain('yamal-gosw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('referenceForKeys labels only known keys and omits unknown ones', () => {
|
||||||
|
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
|
||||||
|
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
|
||||||
|
expect(ref['not-a-real-key-xyz']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allTerritoryKeys unions every nation\'s territory keys (no overlap)', () => {
|
||||||
|
const state: GameState = {
|
||||||
id: 'g',
|
id: 'g',
|
||||||
presetId: 'p',
|
presetId: 'p',
|
||||||
presetTitle: 'P',
|
presetTitle: 'P',
|
||||||
@@ -41,136 +88,156 @@ function makeState(territory: string[]): GameState {
|
|||||||
name: 'UK',
|
name: 'UK',
|
||||||
type: 'major',
|
type: 'major',
|
||||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||||
territory,
|
territory: expandCountriesToKeys(['GBR']),
|
||||||
|
relations: {},
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
id: 'fr',
|
||||||
|
name: 'FR',
|
||||||
|
type: 'major',
|
||||||
|
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||||
|
territory: expandCountriesToKeys(['FRA']),
|
||||||
relations: {},
|
relations: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
pending: [],
|
pending: [],
|
||||||
version: 1,
|
version: 1,
|
||||||
|
unowned: [],
|
||||||
|
pendingNationOps: [],
|
||||||
|
markers: [],
|
||||||
};
|
};
|
||||||
}
|
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||||
|
expect(allTerritoryKeys(state).size).toBe(58);
|
||||||
describe('server/regions (real geo)', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
_resetRegionsForTests();
|
|
||||||
loadRegions(root);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads public/regions.geo.json (hasRegionData becomes true)', () => {
|
it('hasAdjacency() is true: the region-adjacency manifest loaded (v0.21 #16 P2)', () => {
|
||||||
expect(hasRegionData()).toBe(true);
|
expect(hasAdjacency()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loadRegions is idempotent: a second call keeps the loaded data', () => {
|
it('neighboursOf returns the union of bordering keys (deduped, sorted, queried excluded)', () => {
|
||||||
loadRegions(root);
|
// known mutual pairs from the manifest (verified in the Stage 1 spot checks)
|
||||||
expect(hasRegionData()).toBe(true);
|
const moselle = neighboursOf(['moselle-rygy']);
|
||||||
|
expect(moselle).toContain('baden-wurttemberg-oyje'); // FRA/DEU border
|
||||||
|
expect(neighboursOf(['baden-wurttemberg-oyje'])).toContain('moselle-rygy'); // mutual
|
||||||
|
expect(neighboursOf(['acre-ekzw'])).toContain('loreto-ipkx'); // BRA/PER border
|
||||||
|
expect(neighboursOf(['loreto-ipkx'])).toContain('acre-ekzw'); // mutual
|
||||||
|
|
||||||
|
// union + dedupe + sorted + the queried keys themselves excluded
|
||||||
|
const union = neighboursOf(['moselle-rygy', 'baden-wurttemberg-oyje']);
|
||||||
|
expect(union).toEqual([...union].sort());
|
||||||
|
expect(new Set(union).size).toBe(union.length); // no duplicates
|
||||||
|
expect(union).not.toContain('moselle-rygy');
|
||||||
|
expect(union).not.toContain('baden-wurttemberg-oyje');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expandTerritory expands an ISO_A3 into its sub-region ids (unique, all with that parent)', () => {
|
it('neighboursOf returns [] for unknown keys or an empty set', () => {
|
||||||
const ids = expandTerritory(['FRA']);
|
expect(neighboursOf(['not-a-real-key-xyz'])).toEqual([]);
|
||||||
expect(ids.length).toBeGreaterThan(1); // France is subdivided, not one region
|
expect(neighboursOf([])).toEqual([]);
|
||||||
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', () => {
|
it('(v0.27 #44) hasCentroids() is true and centroidOf resolves a real key to a plausible location', () => {
|
||||||
expect(expandTerritory(['FRA', 'FRA'])).toEqual(expandTerritory(['FRA']));
|
expect(hasCentroids()).toBe(true);
|
||||||
// two countries: the union, in first-seen order (the ids are disjoint)
|
const ain = centroidOf('ain-lqtt');
|
||||||
expect(expandTerritory(['FRA', 'DEU'])).toEqual([
|
expect(ain).toBeDefined();
|
||||||
...expandTerritory(['FRA']),
|
expect(ain!.lat).toBeCloseTo(46.16, 1);
|
||||||
...expandTerritory(['DEU']),
|
expect(ain!.lon).toBeCloseTo(4.8, 1);
|
||||||
]);
|
expect(ain!.spread).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expandTerritory of an empty or unknown list yields no regions', () => {
|
it('(v0.27 #44) centroidOf returns undefined for an unknown key', () => {
|
||||||
expect(expandTerritory([])).toEqual([]);
|
expect(centroidOf('not-a-real-key-xyz')).toBeUndefined();
|
||||||
expect(expandTerritory(['XXX'])).toEqual([]); // no country with that code
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('regionNamesFor maps every known id to "name (parent)"', () => {
|
describe('graceful degradation with no region data', () => {
|
||||||
const ids = expandTerritory(['DEU']);
|
beforeEach(() => resetRegions());
|
||||||
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', () => {
|
it('expands to the raw input, omits references, and reports no data', () => {
|
||||||
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);
|
expect(hasRegionData()).toBe(false);
|
||||||
|
// no manifest -> the country list passes through unchanged (old model)
|
||||||
|
expect(expandCountriesToKeys(['FRA', 'GBR'])).toEqual(['FRA', 'GBR']);
|
||||||
|
expect(referenceForKeys(['FRA'])).toEqual({});
|
||||||
|
expect(validKeys().size).toBe(0);
|
||||||
|
expect(regionName('FRA')).toBeUndefined();
|
||||||
|
// and no adjacency: the LLM tool / frontier field switch off
|
||||||
|
expect(hasAdjacency()).toBe(false);
|
||||||
|
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
|
||||||
|
// and no centroids: the marker-placement path degrades to a no-op
|
||||||
|
expect(hasCentroids()).toBe(false);
|
||||||
|
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expandTerritory returns its input unchanged (whole-country fallback)', () => {
|
afterAll(() => resetRegions());
|
||||||
expect(expandTerritory(['FRA'])).toEqual(['FRA']);
|
});
|
||||||
expect(expandTerritory([])).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('regionNamesFor is empty and regionLabel falls back to the bare id', () => {
|
describe('loadRegions resolves the manifest per environment (dev public/ vs prod dist/)', () => {
|
||||||
expect(regionNamesFor(['FRA'])).toEqual({});
|
const realRoot = root;
|
||||||
expect(regionLabel('FRA')).toBe('FRA');
|
// A root that mimics the prod Docker image: no public/, the region files live
|
||||||
});
|
// in dist/ (where Vite copies public/* at build). This is the exact layout that
|
||||||
|
// broke v0.19.0 in prod (the loader only looked in public/).
|
||||||
|
const base = mkdtempSync(path.join(tmpdir(), 'history-regions-'));
|
||||||
|
const prodRoot = path.join(base, 'prod');
|
||||||
|
|
||||||
it('migrateTerritoryToRegions is a no-op (returns the state untouched)', () => {
|
beforeEach(() => {
|
||||||
const state = makeState(['FRA']);
|
mkdirSync(path.join(prodRoot, 'dist'), { recursive: true });
|
||||||
const out = migrateTerritoryToRegions(state);
|
copyFileSync(
|
||||||
expect(out).toBe(state);
|
path.join(realRoot, 'public', 'region-keys.json'),
|
||||||
expect(out.nations.uk.territory).toEqual(['FRA']);
|
path.join(prodRoot, 'dist', 'region-keys.json'),
|
||||||
});
|
|
||||||
|
|
||||||
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'] },
|
|
||||||
);
|
);
|
||||||
|
copyFileSync(
|
||||||
|
path.join(realRoot, 'public', 'regions.geo.json'),
|
||||||
|
path.join(prodRoot, 'dist', 'regions.geo.json'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => resetRegions());
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(base, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads from dist/ when public/ is absent (the prod container layout)', () => {
|
||||||
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
expect(hasRegionData()).toBe(true);
|
expect(hasRegionData()).toBe(true);
|
||||||
expect(expandTerritory(['GBR'])).toEqual(['GB-01', 'GB-02']);
|
expect(validKeys().size).toBe(1800);
|
||||||
expect(regionNamesFor(['GB-01'])).toEqual({ 'GB-01': 'Greater London (GBR)' });
|
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
|
||||||
const state = makeState(['GBR']);
|
});
|
||||||
expect(migrateTerritoryToRegions(state).nations.uk.territory).toEqual(['GB-01', 'GB-02']);
|
|
||||||
|
it('still loads from public/ (the dev layout, the first candidate)', () => {
|
||||||
|
expect(loadRegions(realRoot)).toBe(true);
|
||||||
|
expect(validKeys().size).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => {
|
||||||
|
// this fixture copies only region-keys.json + regions.geo.json (no
|
||||||
|
// region-adjacency.json) — the server must boot and serve turns with the
|
||||||
|
// tool/frontier simply switched off.
|
||||||
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
|
expect(hasAdjacency()).toBe(false);
|
||||||
|
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
|
||||||
|
// and no centroids (not copied into this dist/) -> marker placement degrades
|
||||||
|
expect(hasCentroids()).toBe(false);
|
||||||
|
expect(centroidOf('ain-lqtt')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => {
|
||||||
|
copyFileSync(
|
||||||
|
path.join(realRoot, 'public', 'region-centroids.json'),
|
||||||
|
path.join(prodRoot, 'dist', 'region-centroids.json'),
|
||||||
|
);
|
||||||
|
expect(loadRegions(prodRoot)).toBe(true);
|
||||||
|
expect(hasCentroids()).toBe(true);
|
||||||
|
const ain = centroidOf('ain-lqtt');
|
||||||
|
expect(ain).toBeDefined();
|
||||||
|
expect(ain!.lat).toBeCloseTo(46.16, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and no data when neither public/ nor dist/ holds the files', () => {
|
||||||
|
const emptyRoot = path.join(base, 'empty');
|
||||||
|
mkdirSync(emptyRoot, { recursive: true });
|
||||||
|
expect(loadRegions(emptyRoot)).toBe(false);
|
||||||
|
expect(hasRegionData()).toBe(false);
|
||||||
|
expect(validKeys().size).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+219
-136
@@ -1,102 +1,186 @@
|
|||||||
// Sub-national region data: loaded ONCE at server boot from
|
// Region-key data access (server). Loads the deterministic region-key manifest
|
||||||
// `public/regions.geo.json` (dev) or `dist/regions.geo.json` (prod — Vite copies
|
// (region-keys.json: stable feature id -> region key), the sub-national geo
|
||||||
// `public/` → `dist/`). It builds two in-memory maps that power the
|
// (regions.geo.json: feature id -> { name, parent }), and — optionally — the
|
||||||
// sub-region-level territory model (v0.19):
|
// precomputed region-adjacency manifest (region-adjacency.json: region key ->
|
||||||
|
// bordering region keys, built by scripts/build-region-adjacency.mjs) and the
|
||||||
|
// precomputed region-centroid manifest (region-centroids.json: region key ->
|
||||||
|
// { lat, lon, spread }, built by scripts/build-region-centroids.mjs) — all
|
||||||
|
// from public/ in dev (Vite serves it) or dist/ in prod (Vite copies
|
||||||
|
// public/* -> dist/ at build, and the Docker image keeps only dist/) — and
|
||||||
|
// joins them into the lookups the region-level ownership model needs:
|
||||||
//
|
//
|
||||||
// - `regionIndex` : region id → { name, parent } (parent = ISO_A3 country)
|
// - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions (v0.19, #16 P1)
|
||||||
// - `regionsByCountry`: ISO_A3 country → array of its region ids
|
// - build the LLM "regions" reference table (key -> "name (parent ISO_A3)")
|
||||||
|
// - the set of all valid region keys (to ignore hallucinated keys in applyTurnResult)
|
||||||
|
// - neighboursOf(): the union of the bordering keys for a set of keys — the
|
||||||
|
// data behind the LLM's getNeighbours tool and the player-frontier prompt
|
||||||
|
// field (v0.21, #16 P2)
|
||||||
|
// - centroidOf(): a region key's precomputed anchor point ({ lat, lon, spread })
|
||||||
|
// — the data behind LLM-placed map markers (v0.27, #44)
|
||||||
//
|
//
|
||||||
// These are used to (a) expand a nation's ISO_A3 territory into its sub-region
|
// Deliberately turf-free: it reads the JSON files with plain JSON.parse and
|
||||||
// ids at game creation / load-time migration, and (b) hand the LLM a human
|
// never imports @turf/@types/geojson (importing one pulls @types/geojson into
|
||||||
// name for each region id it may reference in a TurnResult.
|
// the tsc program and breaks WorldMap.tsx; see the v0.17 changelog note).
|
||||||
//
|
// Loaded once at boot; the accessors below are pure data reads thereafter.
|
||||||
// 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 { existsSync, readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
|
|
||||||
/** A region's stable identity: display name + the parent country's ISO_A3. */
|
/** { name, parent } for one sub-region, where parent is the ISO_A3 country. */
|
||||||
export interface RegionInfo {
|
interface RegionInfo {
|
||||||
name: string;
|
name: string;
|
||||||
parent: string;
|
parent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let regionIndex: Record<string, RegionInfo> = {};
|
interface RegionData {
|
||||||
let regionsByCountry: Record<string, string[]> = {};
|
/** stable feature id -> region key (from public/region-keys.json). */
|
||||||
let loaded = false;
|
idToKey: Record<string, string>;
|
||||||
|
/** region key -> { name, parent }. */
|
||||||
/** True once the region geo has been loaded successfully. */
|
keyToInfo: Record<string, RegionInfo>;
|
||||||
export function hasRegionData(): boolean {
|
/** ISO_A3 parent -> region keys of its sub-regions (file order, stable). */
|
||||||
return loaded;
|
keysByCountry: Record<string, string[]>;
|
||||||
|
/** the set of all region keys. */
|
||||||
|
validKeys: Set<string>;
|
||||||
|
/** region key -> bordering region keys (from public/region-adjacency.json). */
|
||||||
|
adjacency: Record<string, string[]>;
|
||||||
|
/** region key -> anchor point (from public/region-centroids.json). */
|
||||||
|
centroids: Record<string, { lat: number; lon: number; spread: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let data: RegionData | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* (Re)build the in-memory region maps from the region geo. Idempotent: a second
|
* Load (or reload) the region-key manifest, geo, adjacency, and centroids.
|
||||||
* call is a no-op. Tries `<root>/public/regions.geo.json` first (dev), then
|
* Idempotent.
|
||||||
* `<root>/dist/regions.geo.json` (prod, where Vite copies `public/` → `dist/`).
|
* The files are served to the client from `public/` in dev (Vite serves it
|
||||||
* If no candidate file is found, or it fails to parse, the maps stay empty and
|
* directly) and from `dist/` in prod (Vite copies `public/*` → `dist/` at build
|
||||||
* `hasRegionData()` stays false (graceful fallback to ISO_A3) — never throws.
|
* and the Docker image keeps only `dist/`), so probe `public/` then `dist/`
|
||||||
|
* (plus the root as a last resort) and use the first directory that holds the
|
||||||
|
* key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing
|
||||||
|
* or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply
|
||||||
|
* report "no adjacency" (the LLM tool and the frontier prompt field switch
|
||||||
|
* off; everything else keeps working). The centroid manifest is likewise
|
||||||
|
* OPTIONAL (v0.27, #44): when missing or broken, `centroids` stays empty and
|
||||||
|
* `centroidOf`/`hasCentroids` report "no centroids" (new map markers can't be
|
||||||
|
* placed, but everything else keeps working). Returns true when the core pair loaded
|
||||||
|
* cleanly; a missing/broken pair clears the data and returns false so the
|
||||||
|
* server still boots and degrades gracefully (expansion becomes a no-op,
|
||||||
|
* ownership falls back to the raw input keys).
|
||||||
*/
|
*/
|
||||||
export function loadRegions(root: string): void {
|
export function loadRegions(root: string): boolean {
|
||||||
if (loaded) return;
|
const keyFile = 'region-keys.json';
|
||||||
const candidates = [
|
const geoFile = 'regions.geo.json';
|
||||||
path.join(root, 'public', 'regions.geo.json'),
|
const adjacencyFile = 'region-adjacency.json';
|
||||||
path.join(root, 'dist', 'regions.geo.json'),
|
const centroidsFile = 'region-centroids.json';
|
||||||
];
|
const base = [
|
||||||
const file = candidates.find((c) => existsSync(c));
|
path.join(root, 'public'), // dev: Vite serves public/ at /
|
||||||
if (!file) {
|
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
|
||||||
console.warn(
|
root, // last resort
|
||||||
'[history] regions.geo.json not found (tried: ' + candidates.join(', ') +
|
].find((d) => existsSync(path.join(d, keyFile)) && existsSync(path.join(d, geoFile)));
|
||||||
') — falling back to whole-country (ISO_A3) territory',
|
if (!base) {
|
||||||
);
|
data = null;
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
const keyPath = path.join(base, keyFile);
|
||||||
|
const geoPath = path.join(base, geoFile);
|
||||||
try {
|
try {
|
||||||
const geo = JSON.parse(readFileSync(file, 'utf8')) as {
|
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
|
||||||
features?: Array<{
|
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
|
||||||
id?: string | number;
|
features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>;
|
||||||
properties?: { name?: string; parent?: string };
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
const index: Record<string, RegionInfo> = {};
|
const keyToInfo: Record<string, RegionInfo> = {};
|
||||||
const byCountry: Record<string, string[]> = {};
|
const keysByCountry: Record<string, string[]> = {};
|
||||||
|
const validKeys = new Set<string>();
|
||||||
for (const f of geo.features ?? []) {
|
for (const f of geo.features ?? []) {
|
||||||
const id = f.id == null ? '' : String(f.id);
|
const id = f.id == null ? '' : String(f.id);
|
||||||
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
|
const key = idToKey[id];
|
||||||
|
if (!key) continue; // a geo feature with no manifest entry is skipped
|
||||||
const name = typeof f.properties?.name === 'string' ? f.properties.name : '';
|
const name = typeof f.properties?.name === 'string' ? f.properties.name : '';
|
||||||
if (!id || !parent) continue;
|
const parent = typeof f.properties?.parent === 'string' ? f.properties.parent : '';
|
||||||
index[id] = { name, parent };
|
keyToInfo[key] = { name, parent };
|
||||||
(byCountry[parent] ??= []).push(id);
|
validKeys.add(key);
|
||||||
|
if (parent) (keysByCountry[parent] ??= []).push(key);
|
||||||
}
|
}
|
||||||
regionIndex = index;
|
|
||||||
regionsByCountry = byCountry;
|
// Adjacency is optional: a missing file means "no adjacency data".
|
||||||
loaded = Object.keys(index).length > 0;
|
let adjacency: Record<string, string[]> = {};
|
||||||
} catch (e) {
|
const adjacencyPath = path.join(base, adjacencyFile);
|
||||||
console.warn('[history] failed to load regions.geo.json (' + file + '):', e);
|
if (existsSync(adjacencyPath)) {
|
||||||
|
try {
|
||||||
|
adjacency = JSON.parse(readFileSync(adjacencyPath, 'utf8')) as Record<string, string[]>;
|
||||||
|
} catch (err) {
|
||||||
|
// A corrupt manifest must not kill the core region data: degrade to
|
||||||
|
// "no adjacency" (the LLM tool / frontier guidance simply switch off).
|
||||||
|
console.error('[regions] failed to load region-adjacency.json, continuing without it', err);
|
||||||
|
adjacency = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centroids are optional too (v0.27, #44): a missing/broken file means
|
||||||
|
// "no centroid data" — new markers can't be placed (their adds are
|
||||||
|
// dropped), but everything else keeps working.
|
||||||
|
let centroids: Record<string, { lat: number; lon: number; spread: number }> = {};
|
||||||
|
const centroidsPath = path.join(base, centroidsFile);
|
||||||
|
if (existsSync(centroidsPath)) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(centroidsPath, 'utf8')) as Record<
|
||||||
|
string,
|
||||||
|
{ lat?: unknown; lon?: unknown; spread?: unknown }
|
||||||
|
>;
|
||||||
|
// Keep only well-formed entries; a corrupt file degrades to "none".
|
||||||
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
|
if (
|
||||||
|
v &&
|
||||||
|
typeof v.lat === 'number' &&
|
||||||
|
typeof v.lon === 'number' &&
|
||||||
|
typeof v.spread === 'number' &&
|
||||||
|
Number.isFinite(v.lat) &&
|
||||||
|
Number.isFinite(v.lon) &&
|
||||||
|
Number.isFinite(v.spread)
|
||||||
|
) {
|
||||||
|
centroids[k] = { lat: v.lat, lon: v.lon, spread: v.spread };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[regions] failed to load region-centroids.json, continuing without it', err);
|
||||||
|
centroids = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency, centroids };
|
||||||
|
return validKeys.size > 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[regions] failed to load region data', err);
|
||||||
|
data = null;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test/dev seam: drop the loaded data (the next accessors behave as "no data"). */
|
||||||
|
export function resetRegions(): void {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the manifest + geo loaded cleanly. */
|
||||||
|
export function hasRegionData(): boolean {
|
||||||
|
return data !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expand a list of ISO_A3 country codes into the union of all their sub-region
|
* Expand a nation's ISO_A3 country list to the region keys of all its sub-regions
|
||||||
* ids (deduped, preserving first-seen order). When the region data is not
|
* (file order, deduped). This is what turns a preset's country-level territory
|
||||||
* loaded, returns the input unchanged (graceful fallback to ISO_A3 territory).
|
* into region-level ownership at game creation. Returns the input unchanged when
|
||||||
|
* no region data is loaded (graceful degradation — behaves like the old model).
|
||||||
*/
|
*/
|
||||||
export function expandTerritory(countryCodes: string[]): string[] {
|
export function expandCountriesToKeys(countries: string[]): string[] {
|
||||||
if (!loaded) return countryCodes;
|
if (!data) return countries;
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const code of countryCodes) {
|
const seen = new Set<string>();
|
||||||
for (const id of regionsByCountry[code] ?? []) {
|
for (const c of countries) {
|
||||||
if (!seen.has(id)) {
|
for (const key of data.keysByCountry[c] ?? []) {
|
||||||
seen.add(id);
|
if (!seen.has(key)) {
|
||||||
out.push(id);
|
seen.add(key);
|
||||||
|
out.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,84 +188,83 @@ export function expandTerritory(countryCodes: string[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `{ [regionId]: "name (parent)" }` object for the given region ids, for the
|
* Build the LLM reference table: region key -> "<name> (<parent ISO_A3>)", e.g.
|
||||||
* LLM prompt. Unknown ids (not in the loaded index) are skipped. Empty when the
|
* `moselle-rygy -> "Moselle (FRA)"`. Only the given keys are included (the caller
|
||||||
* region data is not loaded.
|
* passes the game's owned-region universe) so the prompt stays bounded.
|
||||||
*/
|
*/
|
||||||
export function regionNamesFor(ids: Iterable<string>): Record<string, string> {
|
export function referenceForKeys(keys: Iterable<string>): Record<string, string> {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
for (const id of ids) {
|
if (!data) return out;
|
||||||
const info = regionIndex[id];
|
for (const key of keys) {
|
||||||
if (info) out[id] = `${info.name} (${info.parent})`;
|
const info = data.keyToInfo[key];
|
||||||
|
if (info) out[key] = info.parent ? `${info.name} (${info.parent})` : info.name;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The region key's human name (for the dossier), or undefined if unknown. */
|
||||||
* Human-readable label for a single region id: `id — name (parent)`, or just
|
export function regionName(key: string): string | undefined {
|
||||||
* the id when it is unknown (e.g. a region that predates the geo or a
|
return data?.keyToInfo[key]?.name;
|
||||||
* 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). */
|
/** The union of every nation's territory region keys (the game's region universe). */
|
||||||
const ISO_A3_RE = /^[A-Z]{3}$/;
|
export function allTerritoryKeys(state: GameState): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
/**
|
|
||||||
* 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)) {
|
for (const n of Object.values(state.nations)) {
|
||||||
if (!n.territory.some((t) => ISO_A3_RE.test(t))) continue;
|
for (const key of n.territory) out.add(key);
|
||||||
const isoCodes = n.territory.filter((t) => ISO_A3_RE.test(t));
|
|
||||||
const existingRegions = n.territory.filter((t) => !ISO_A3_RE.test(t));
|
|
||||||
const merged = expandTerritory(isoCodes).concat(existingRegions);
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const id of merged) {
|
|
||||||
if (!seen.has(id)) {
|
|
||||||
seen.add(id);
|
|
||||||
out.push(id);
|
|
||||||
}
|
}
|
||||||
}
|
return out;
|
||||||
n.territory = out;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Test-only seams ---------------------------------------------------------
|
/** The set of all valid region keys (to ignore hallucinated keys in applyTurnResult). */
|
||||||
|
export function validKeys(): ReadonlySet<string> {
|
||||||
/**
|
return data ? data.validKeys : new Set<string>();
|
||||||
* 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
|
* True when the region-adjacency manifest loaded with at least one entry.
|
||||||
* test can drive `expandTerritory` / `regionNamesFor` / `regionLabel` /
|
* Callers (the server's jump route) use this to decide whether to hand the
|
||||||
* `migrateTerritoryToRegions` against a small, deterministic fixture without
|
* engine an adjacency resolver (which turns on the LLM's getNeighbours tool
|
||||||
* reading the 3.4 MB geo file. `loaded` becomes true iff `index` is non-empty.
|
* and the frontier prompt field) or fall back to the plain chat() path.
|
||||||
*/
|
*/
|
||||||
export function _setRegionsForTests(
|
export function hasAdjacency(): boolean {
|
||||||
index: Record<string, RegionInfo>,
|
return data !== null && Object.keys(data.adjacency).length > 0;
|
||||||
byCountry: Record<string, string[]>,
|
}
|
||||||
): void {
|
|
||||||
regionIndex = index;
|
/**
|
||||||
regionsByCountry = byCountry;
|
* True when the region-centroid manifest loaded with at least one entry
|
||||||
loaded = Object.keys(index).length > 0;
|
* (v0.27, #44). The server's jump route uses this to decide whether to hand
|
||||||
|
* the engine a centroid resolver (which lets a turn place new map markers)
|
||||||
|
* or skip marker placement (existing markers are still maintained).
|
||||||
|
*/
|
||||||
|
export function hasCentroids(): boolean {
|
||||||
|
return data !== null && Object.keys(data.centroids).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The precomputed anchor point for a region key: the centroid of its
|
||||||
|
* largest-area part, plus a `spread` (≈ 0.1 × that part's smallest bbox
|
||||||
|
* dimension, in degrees) the engine scatters markers within
|
||||||
|
* ±(spread/2) of the centroid. `undefined` for an unknown key or when no
|
||||||
|
* centroid data is loaded (v0.27, #44).
|
||||||
|
*/
|
||||||
|
export function centroidOf(key: string): { lat: number; lon: number; spread: number } | undefined {
|
||||||
|
return data?.centroids[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The union of the region keys that share a land border with any of the given
|
||||||
|
* keys — deduplicated, the queried keys themselves excluded, and sorted for
|
||||||
|
* determinism. This is the data behind the LLM's getNeighbours tool and the
|
||||||
|
* player-frontier computation in the turn prompt (v0.21, #16 P2). Returns
|
||||||
|
* `[]` when no region data (or no adjacency manifest) is loaded.
|
||||||
|
*/
|
||||||
|
export function neighboursOf(keys: Iterable<string>): string[] {
|
||||||
|
if (!data) return [];
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const key of keys) {
|
||||||
|
for (const n of data.adjacency[key] ?? []) out.add(n);
|
||||||
|
}
|
||||||
|
for (const key of keys) out.delete(key); // never report a key as its own neighbour
|
||||||
|
return [...out].sort();
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-12
@@ -2,7 +2,7 @@
|
|||||||
// sets LLM_STUB=1) so the whole loop is reproducible with no live model. It
|
// sets LLM_STUB=1) so the whole loop is reproducible with no live model. It
|
||||||
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
|
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
|
||||||
import { createLlmClient, type ChatMessage, type Transport } from './llm';
|
import { createLlmClient, type ChatMessage, type Transport } from './llm';
|
||||||
import { addDays, clampStat, type TurnEventSpec, type TurnResult } from '../shared/protocol';
|
import { addDays, clampStat, type TerritoryChange, type TurnEventSpec, type TurnResult } from '../shared/protocol';
|
||||||
import type { RoundItem } from '../shared/types';
|
import type { RoundItem } from '../shared/types';
|
||||||
|
|
||||||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||||||
@@ -13,11 +13,31 @@ export interface StubPayload {
|
|||||||
turn: number;
|
turn: number;
|
||||||
date: string;
|
date: string;
|
||||||
playerNationId: string;
|
playerNationId: string;
|
||||||
/** As of v0.19 territory is a list of sub-region ids (not ISO_A3 codes). */
|
|
||||||
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
|
nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
|
||||||
};
|
};
|
||||||
/** The raw pending round the engine emitted (one entry per queued item). */
|
/** The raw pending round the engine emitted (one entry per queued item). */
|
||||||
pending?: RoundItem[];
|
pending?: RoundItem[];
|
||||||
|
/**
|
||||||
|
* Region keys owned by other nations that share a land border with the
|
||||||
|
* player's territory — the engine's `frontier` field, computed from the
|
||||||
|
* adjacency manifest (v0.21, #16 P2). The stub cannot call tools, so it
|
||||||
|
* reads the frontier from the prompt instead.
|
||||||
|
*/
|
||||||
|
frontier?: string[];
|
||||||
|
/**
|
||||||
|
* (v0.22, #16 P3) Region keys currently in no man's land (`state.unowned`),
|
||||||
|
* emitted by the engine's user prompt. The stub ignores this field — it
|
||||||
|
* never releases a region (see deterministicTurn), so no-man's-land behavior
|
||||||
|
* is covered by unit tests against applyTurnResult, not by the stub.
|
||||||
|
*/
|
||||||
|
unowned?: string[];
|
||||||
|
/**
|
||||||
|
* (v0.27, #44) Current map markers (`{ id, type, name, regionKey, owner }`),
|
||||||
|
* emitted by the engine's user prompt. The stub ignores this field — it
|
||||||
|
* never places or removes a marker (see deterministicTurn), so the marker
|
||||||
|
* path is covered by unit tests against applyTurnResult, not by the stub.
|
||||||
|
*/
|
||||||
|
markers?: Array<{ id: string; type: string; name: string; regionKey: string; owner: string | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncate(s: string, n: number): string {
|
function truncate(s: string, n: number): string {
|
||||||
@@ -31,12 +51,27 @@ function truncate(s: string, n: number): string {
|
|||||||
* single political "The world moves on" event when the round is empty — so a
|
* 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
|
* 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
|
* player's military to (current + 5) and allies the player with the first other
|
||||||
* nation (by key order), advancing the date by 2 days. As of v0.19, when the
|
* nation, advancing the date by 2 days.
|
||||||
* round is non-empty it also makes exactly **one** territory move: the player
|
*
|
||||||
* annexes the lexicographically-first sub-region id of that first other
|
* (v0.21, #16 P2) It also makes ONE contiguous conquest per turn: the player
|
||||||
* nation's territory (an empty round makes no move, and a player with nothing
|
* gains the first frontier region (sorted order) from its current owner, and
|
||||||
* to annex from that nation also makes no move) — so unit tests and e2e can
|
* that owner loses it. The stub bypasses the tool loop (it answers with plain
|
||||||
* assert the exact region that changes hands.
|
* content in one round), so the frontier comes from the prompt payload, not
|
||||||
|
* from a getNeighbours call. With an empty frontier (no controllable border)
|
||||||
|
* no territory change is emitted, keeping every existing assertion stable.
|
||||||
|
*
|
||||||
|
* (v0.22, #16 P3) The stub NEVER releases a region to no man's land (it
|
||||||
|
* always returns `unowned: []`) — e2e assertions rely on its fully-deterministic
|
||||||
|
* one-conquest behavior, so the release/re-claim path is exercised by unit
|
||||||
|
* tests only (see server/engine.test.ts).
|
||||||
|
*
|
||||||
|
* (v0.23, #16 P4a) The stub likewise NEVER proposes nation-management ops (it
|
||||||
|
* always returns `nationOps: []`) — the propose→confirm/reject flow is
|
||||||
|
* covered by unit tests only, keeping the e2e assertions untouched.
|
||||||
|
*
|
||||||
|
* (v0.27, #44) The stub likewise NEVER places or removes a map marker (it
|
||||||
|
* always returns `markers: { add: [], remove: [] }`) — the marker path is
|
||||||
|
* covered by unit tests only, keeping the e2e assertions untouched.
|
||||||
*/
|
*/
|
||||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||||
const state = p.state;
|
const state = p.state;
|
||||||
@@ -47,9 +82,23 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
|
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
|
||||||
: 'us';
|
: 'us';
|
||||||
const items = p.pending ?? [];
|
const items = p.pending ?? [];
|
||||||
// The one deterministic per-turn territory move (see the doc above).
|
|
||||||
const otherTerritory = [...(state?.nations?.[otherId]?.territory ?? [])].sort();
|
// The one deterministic region transfer: the player takes the first
|
||||||
const gain = items.length > 0 && otherTerritory.length > 0 ? [otherTerritory[0]] : [];
|
// frontier region (sorted → stable) from whichever other nation owns it.
|
||||||
|
let territoryChanges: TerritoryChange[] = [];
|
||||||
|
const frontier = p.frontier ?? [];
|
||||||
|
if (state && frontier.length > 0) {
|
||||||
|
const key = frontier[0];
|
||||||
|
const loserId = Object.keys(state.nations).find(
|
||||||
|
(id) => id !== pid && (state.nations[id].territory ?? []).includes(key),
|
||||||
|
);
|
||||||
|
if (loserId) {
|
||||||
|
territoryChanges = [
|
||||||
|
{ nation: pid, gain: [key], lose: [] },
|
||||||
|
{ nation: loserId, gain: [], lose: [key] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const events: TurnEventSpec[] =
|
const events: TurnEventSpec[] =
|
||||||
items.length === 0
|
items.length === 0
|
||||||
@@ -85,7 +134,10 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
events,
|
events,
|
||||||
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
stateChanges: [{ nation: pid, stat: 'military', op: 'set', value: mil }],
|
||||||
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
relations: otherId ? [{ a: pid, b: otherId, set: 'ally' }] : [],
|
||||||
territoryChanges: gain.length > 0 ? [{ nation: pid, gain, lose: [] }] : [],
|
territoryChanges,
|
||||||
|
unowned: [], // the stub never creates no man's land (see doc comment)
|
||||||
|
nationOps: [], // the stub never proposes nation ops (see doc comment)
|
||||||
|
markers: { add: [], remove: [] }, // the stub never touches markers (see doc comment)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+301
-30
@@ -6,10 +6,12 @@ import type {
|
|||||||
EventOutcome,
|
EventOutcome,
|
||||||
EventType,
|
EventType,
|
||||||
GameState,
|
GameState,
|
||||||
|
MarkerType,
|
||||||
Nation,
|
Nation,
|
||||||
RelationStatus,
|
RelationStatus,
|
||||||
StatKey,
|
StatKey,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { MARKER_TYPES } from './types';
|
||||||
|
|
||||||
/** A numeric change to one of a nation's 0–100 stats. */
|
/** A numeric change to one of a nation's 0–100 stats. */
|
||||||
export type StateChange =
|
export type StateChange =
|
||||||
@@ -23,19 +25,88 @@ export interface RelationChange {
|
|||||||
set: RelationStatus;
|
set: RelationStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A nation gains/loses region keys (see `public/region-keys.json`) 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 {
|
export interface TerritoryChange {
|
||||||
nation: string;
|
nation: string;
|
||||||
gain: string[];
|
gain: string[];
|
||||||
lose: string[];
|
lose: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Nation-management ops (v0.23 P4a / v0.24 P4b, #16: the hybrid "LLM
|
||||||
|
// proposes, player confirms" model) ------------------------------------------
|
||||||
|
//
|
||||||
|
// Unlike stat / relation / territory deltas, a NationOp proposed in a turn is
|
||||||
|
// NOT applied by that turn. It is stored as a *pending proposal* in
|
||||||
|
// `GameState.pendingNationOps` and only takes effect when the player confirms
|
||||||
|
// it (optionally editing it) or is dropped when the player rejects it. All
|
||||||
|
// three kinds are implemented: rename (v0.23 P4a), split and merge (v0.24 P4b).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity reference a nation is addressed by inside a {@link NationOp}.
|
||||||
|
* The LLM may use either the nation's stable `id` (the `nations` record key,
|
||||||
|
* e.g. `f`) or its human-readable `name` (e.g. `France`); the engine resolves
|
||||||
|
* it to a concrete `nationId` (id first, then a unique name match — see the
|
||||||
|
* engine's `resolveNationRef`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **rename** proposal: change a nation's human-readable `name`. Only the
|
||||||
|
* `name` changes — the stable `id`, its territory keys, its relations (keyed
|
||||||
|
* by id), and the player pointer are all left untouched, so nothing downstream
|
||||||
|
* breaks. `to` is the new display name.
|
||||||
|
*/
|
||||||
|
export interface RenameOp {
|
||||||
|
kind: 'rename';
|
||||||
|
/** The target nation's id or current name (see the note above). */
|
||||||
|
nationRef: string;
|
||||||
|
/** The new display name to apply when the player confirms. */
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **split** proposal (v0.24, #16 P4b). Describes the new nations a source
|
||||||
|
* nation is divided into. On confirm the source is dissolved: each part gets a
|
||||||
|
* fresh id, the region keys it was given (restricted to keys the source
|
||||||
|
* actually owns — first claim wins), fresh baseline stats, and a copy of the
|
||||||
|
* source's relations; any of the source's unclaimed keys go to `parts[0]`.
|
||||||
|
* When the source is the player's own nation the player chooses which part
|
||||||
|
* they lead (the confirm request's `edits.playerPart`, default `parts[0]`).
|
||||||
|
*/
|
||||||
|
export interface SplitOp {
|
||||||
|
kind: 'split';
|
||||||
|
/** The id/name of the nation being split. */
|
||||||
|
nationRef: string;
|
||||||
|
/** The new nations created from the split. */
|
||||||
|
parts: Array<{ name: string; territory?: string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **merge** proposal (v0.24, #16 P4b). Folds several nations into a single
|
||||||
|
* surviving nation. On confirm the survivor keeps its id (renamed to `into`,
|
||||||
|
* or to the confirm request's `edits.name`), its territory becomes the union
|
||||||
|
* of all inputs' territories, it keeps its own stats, and it inherits the
|
||||||
|
* union of all inputs' relations (its own winning per third party, else the
|
||||||
|
* first input's); every non-survivor input is removed. If the player's nation
|
||||||
|
* was a non-survivor input the player pointer is re-pointed to the survivor.
|
||||||
|
*/
|
||||||
|
export interface MergeOp {
|
||||||
|
kind: 'merge';
|
||||||
|
/** The id/name of every nation folded into the result. */
|
||||||
|
nationRefs: string[];
|
||||||
|
/** The id/name of the nation that survives and keeps its identity. */
|
||||||
|
survivingRef: string;
|
||||||
|
/** The name to give the merged nation. */
|
||||||
|
into: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A structural operation on the set of nations that the LLM may propose in a
|
||||||
|
* turn (`TurnResult.nationOps`). See the section comment above for the hybrid
|
||||||
|
* propose→confirm semantics. All three kinds are implemented: rename (P4a),
|
||||||
|
* and split / merge (P4b, v0.24).
|
||||||
|
*/
|
||||||
|
export type NationOp = RenameOp | SplitOp | MergeOp;
|
||||||
|
|
||||||
/** One narrative event the LLM proposes for this turn. */
|
/** One narrative event the LLM proposes for this turn. */
|
||||||
export interface TurnEventSpec {
|
export interface TurnEventSpec {
|
||||||
nation: string;
|
nation: string;
|
||||||
@@ -45,6 +116,100 @@ export interface TurnEventSpec {
|
|||||||
outcome: EventOutcome;
|
outcome: EventOutcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Map markers (v0.27, #44) -------------------------------------------------
|
||||||
|
//
|
||||||
|
// The LLM places named point markers (cities, factories, armies, ports,
|
||||||
|
// capitals, fleets, rocket launch sites, gatherings) anchored to a region
|
||||||
|
// key. It never sees or sets coordinates: the engine derives the position
|
||||||
|
// from the region's precomputed centroid (public/region-centroids.json) plus a
|
||||||
|
// small deterministic offset, and it recomputes each marker's owner from the
|
||||||
|
// region's current owner on every turn (a released region → null).
|
||||||
|
//
|
||||||
|
// The marker id is a STABLE, deterministic 4-letter code (FNV-1a 32-bit →
|
||||||
|
// base 26, the same scheme as the region keys' 4-char suffix) derived from
|
||||||
|
// (type, name, regionKey) — so re-adding an identical marker is idempotent
|
||||||
|
// (same id, skipped) and removing by id is unambiguous.
|
||||||
|
|
||||||
|
/** The spec the LLM emits to place a marker: type, name and the region it sits in. */
|
||||||
|
export interface MarkerSpec {
|
||||||
|
type: MarkerType;
|
||||||
|
name: string;
|
||||||
|
regionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One existing marker as the user prompt presents it to the model (v0.27,
|
||||||
|
* #44): its stable `id` (the model may echo it in `TurnResult.markers.remove`),
|
||||||
|
* type, name, region key, and the id of the nation currently owning its
|
||||||
|
* region (`null` when the region is unowned / no man's land).
|
||||||
|
*/
|
||||||
|
export interface MarkerInfo {
|
||||||
|
id: string;
|
||||||
|
type: MarkerType;
|
||||||
|
name: string;
|
||||||
|
regionKey: string;
|
||||||
|
owner: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-type presentation of a marker: its display label (tooltip /
|
||||||
|
* dossier), its color, and its glyph character (a symbol-layer text label —
|
||||||
|
* a small, font-safe character in front of the name). Pure + shared so the
|
||||||
|
* client's marker layer and any server-side consumers agree on the look.
|
||||||
|
*/
|
||||||
|
export const MARKER_TYPE_INFO: Record<MarkerType, { label: string; color: string; glyph: string }> = {
|
||||||
|
city: { label: 'City', color: '#cbd5e1', glyph: '●' },
|
||||||
|
factory: { label: 'Factory', color: '#fb923c', glyph: '■' },
|
||||||
|
army: { label: 'Army', color: '#ef4444', glyph: '▲' },
|
||||||
|
port: { label: 'Port', color: '#38bdf8', glyph: '◆' },
|
||||||
|
capital: { label: 'Capital', color: '#fbbf24', glyph: '★' },
|
||||||
|
fleet: { label: 'Fleet', color: '#22d3ee', glyph: '◗' },
|
||||||
|
rocket: { label: 'Rocket site', color: '#a78bfa', glyph: '↥' },
|
||||||
|
gathering: { label: 'Gathering', color: '#4ade80', glyph: '◉' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hard cap on a game's total markers (new adds beyond the cap are dropped, v0.27 #44). */
|
||||||
|
export const MAX_MARKERS = 400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNV-1a 32-bit — the same tiny, stable, pure string hash the region keys use
|
||||||
|
* (see scripts/build-region-keys.mjs). Exported so the engine can derive
|
||||||
|
* deterministic per-axis jitter fractions from it (marker placement, v0.27 #44).
|
||||||
|
*/
|
||||||
|
export function fnv1a32(str: string): number {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h ^= str.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */
|
||||||
|
function code4(h: number): string {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
out += String.fromCharCode(97 + (((h >>> (i * 8)) & 0xff) % 26));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stable id for a marker: 4 base-26 lowercase chars from the FNV-1a hash
|
||||||
|
* of `(type, name, regionKey)` (NUL-separated so no field boundary is
|
||||||
|
* ambiguous). Deterministic across runs and platforms (pure string math), so
|
||||||
|
* the same (type, name, regionKey) always maps to the same id — re-adding is
|
||||||
|
* idempotent, removing by id is unambiguous.
|
||||||
|
*/
|
||||||
|
export function markerId(type: MarkerType, name: string, regionKey: string): string {
|
||||||
|
return code4(fnv1a32(`${type}\u0000${name}\u0000${regionKey}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `t` is one of the eight known marker types (v0.27, #44). */
|
||||||
|
export function isMarkerType(t: unknown): t is MarkerType {
|
||||||
|
return typeof t === 'string' && (MARKER_TYPES as string[]).includes(t);
|
||||||
|
}
|
||||||
|
|
||||||
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
||||||
export interface TurnResult {
|
export interface TurnResult {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -52,33 +217,64 @@ export interface TurnResult {
|
|||||||
stateChanges: StateChange[];
|
stateChanges: StateChange[];
|
||||||
relations: RelationChange[];
|
relations: RelationChange[];
|
||||||
territoryChanges: TerritoryChange[];
|
territoryChanges: TerritoryChange[];
|
||||||
|
/**
|
||||||
|
* (v0.22, #16 P3) Region keys that become **unowned** — "no man's land" —
|
||||||
|
* this turn. The engine removes each key from its current (sole) owner so it
|
||||||
|
* ends the turn ownerless and marks it in `GameState.unowned`; the LLM
|
||||||
|
* narrates the cause (war, cataclysm, collapse) in `events`. Re-claiming is
|
||||||
|
* NOT expressed here: a nation re-claims an unowned region with an ordinary
|
||||||
|
* `gain` on that key. Only keys a nation currently owns can be released;
|
||||||
|
* empty on normal turns.
|
||||||
|
*/
|
||||||
|
unowned: string[];
|
||||||
|
/**
|
||||||
|
* (v0.23 P4a / v0.24 P4b, #16) Nation-management ops the LLM proposes this
|
||||||
|
* turn. These are NOT applied by the turn: each is appended to
|
||||||
|
* `GameState.pendingNationOps` as a proposal awaiting the player's
|
||||||
|
* confirm/reject. All three kinds (rename, split, merge) are implemented —
|
||||||
|
* a confirmed rename changes `Nation.name`; a confirmed split dissolves the
|
||||||
|
* source into its parts; a confirmed merge folds its inputs into the
|
||||||
|
* survivor. Empty on turns where the model proposes none.
|
||||||
|
*/
|
||||||
|
nationOps: NationOp[];
|
||||||
|
/**
|
||||||
|
* (v0.27, #44) Map-marker ops this turn. `add` places new named markers
|
||||||
|
* (the engine positions each at its region's centroid + a small
|
||||||
|
* deterministic offset, sets the owner to the region's post-turn owner,
|
||||||
|
* derives the stable id, and drops unknown types/region keys, duplicates,
|
||||||
|
* and adds beyond MAX_MARKERS); `remove` drops markers by their stable id
|
||||||
|
* (a no-op for an id that is not present). Both required, `[]` on turns
|
||||||
|
* with no marker activity.
|
||||||
|
*/
|
||||||
|
markers: { add: MarkerSpec[]; remove: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-region map payload: **sub-region id** -> owning nation + its stable color. */
|
/** Per-region map payload: region key -> owning nation + its stable color. */
|
||||||
export interface RegionOwner {
|
export interface RegionOwner {
|
||||||
color: string;
|
color: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* The authoritative region→owner map, **keyed by sub-region id** (e.g. `FR-57`).
|
* The authoritative ownership map: a **region key** (see `public/region-keys.json`)
|
||||||
* Every sub-region any nation owns appears here; a sub-region in no nation's
|
* -> the owning nation + its stable color. Owned at sub-region granularity (v0.19,
|
||||||
* territory is absent (unowned / no-man's-land) and rendered grey by the map.
|
* #16 P1); a key absent from this map is unowned (rendered grey).
|
||||||
*/
|
*/
|
||||||
export type RegionMap = Record<string, RegionOwner>;
|
export type RegionMap = Record<string, RegionOwner>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
|
* Look up the owner record from the authoritative RegionMap by a RegionMap key.
|
||||||
* The RegionMap is keyed by the **sub-region's own id** (e.g. `FR-57` — never a
|
* The RegionMap is keyed by **region key** (see `public/region-keys.json`) — one
|
||||||
* bare ISO_A3 like `FRA`, and never the repacked numeric `feature.id`), so `key`
|
* per owned sub-region (v0.19, #16 P1) — so `key` is a region key such as
|
||||||
* is a sub-region id such as `FR-57`. Returns `undefined` for an empty/missing
|
* `moselle-rygy`, never a raw feature id such as `FR-57` and never a
|
||||||
* key or an unowned region (one in no nation's territory).
|
* parent-country ISO_A3. Returns `undefined` for an empty/missing key or an
|
||||||
|
* unowned region.
|
||||||
*
|
*
|
||||||
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
|
* Pure + shared: the primitive "key → owner" lookup. The *map feature → owner*
|
||||||
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner}
|
* resolution (deciding which key to pass) lives in {@link mapFeatureOwner} below,
|
||||||
* below, which reads the feature's `properties.regionId` — never the repacked
|
* which reads the feature's stamped `properties.regionKey` — never the repacked
|
||||||
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug
|
* top-level `id` (MapLibre turns it into a numeric index, the "no owner" bug #6)
|
||||||
* #6).
|
* and never the sub-region id.
|
||||||
*/
|
*/
|
||||||
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
|
export function regionOwner(regions: RegionMap, key: string | null | undefined): RegionOwner | undefined {
|
||||||
if (!key) return undefined;
|
if (!key) return undefined;
|
||||||
@@ -87,22 +283,23 @@ export function regionOwner(regions: RegionMap, key: string | null | undefined):
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the owning-nation record for a **rendered map feature** from the
|
* Resolve the owning-nation record for a **rendered map feature** from the
|
||||||
* authoritative RegionMap. Ownership is now at **sub-region** granularity, so a
|
* authoritative RegionMap. The choropleth renders sub-national regions, and the
|
||||||
* feature's owner is found via its **own sub-region id** — the client stamps
|
* RegionMap is now keyed by **region key** (one per sub-region, v0.19 #16 P1),
|
||||||
* the feature's id into `properties.regionId` (see WorldMap `applyRegions`), and
|
* so a feature's owner is found via its `properties.regionKey` — the region key
|
||||||
* we look that up with `regionOwner`. This is the single shared source of truth
|
* the client stamps from `public/region-keys.json` by the feature's stable id
|
||||||
* for that resolution, so the "use `properties.regionId`, never the repacked
|
* (see WorldMap) — and then `regionOwner`. This is the single shared source of
|
||||||
* top-level `feature.id` (a MapLibre numeric index, the "no owner" bug #6)" rule
|
* truth for that resolution, so the "use `regionKey`, never the sub-region `id`
|
||||||
* lives in one tested place rather than in the React component.
|
* / repacked `feature.id`" rule lives in one tested place rather than in the
|
||||||
|
* React component.
|
||||||
*
|
*
|
||||||
* Returns `undefined` for a missing feature/regionId or an unowned region.
|
* Returns `undefined` for a missing feature/regionKey or an unowned region.
|
||||||
*/
|
*/
|
||||||
export function mapFeatureOwner(
|
export function mapFeatureOwner(
|
||||||
regions: RegionMap,
|
regions: RegionMap,
|
||||||
feature: { properties?: { regionId?: unknown } } | null | undefined,
|
feature: { properties?: { regionKey?: unknown } } | null | undefined,
|
||||||
): RegionOwner | undefined {
|
): RegionOwner | undefined {
|
||||||
const regionId = feature?.properties?.regionId;
|
const regionKey = feature?.properties?.regionKey;
|
||||||
return regionOwner(regions, typeof regionId === 'string' ? regionId : '');
|
return regionOwner(regions, typeof regionKey === 'string' ? regionKey : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||||||
@@ -156,6 +353,39 @@ export interface RemoveQueueItemResponse {
|
|||||||
game: GameState;
|
game: GameState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.23 P4a / v0.24 P4b, #16) Confirm a pending nation-op proposal by its
|
||||||
|
* index in `game.pendingNationOps`. A rename takes effect (the player may
|
||||||
|
* override the proposed name via `edits.name`); a split and a merge are fully
|
||||||
|
* applied (v0.24 P4b). No LLM call, no turn advance.
|
||||||
|
*/
|
||||||
|
export interface ConfirmNationOpRequest {
|
||||||
|
index: number;
|
||||||
|
/**
|
||||||
|
* Optional player edits applied on confirm.
|
||||||
|
* - `name`: the final display name — overrides a rename's `to`, a merge's
|
||||||
|
* `into`, or (for a split) the name of the part the player leads.
|
||||||
|
* - `playerPart`: for a split of the player's own nation, which part (index
|
||||||
|
* into the op's `parts`) the player will lead (default `0` when missing or
|
||||||
|
* out of range, so the player is never orphaned).
|
||||||
|
*/
|
||||||
|
edits?: { name?: string; playerPart?: number };
|
||||||
|
}
|
||||||
|
export interface ConfirmNationOpResponse {
|
||||||
|
game: GameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.23, #16 P4a) Reject (drop) a pending nation-op proposal by its index
|
||||||
|
* in `game.pendingNationOps` without applying it. No LLM call, no turn advance.
|
||||||
|
*/
|
||||||
|
export interface RejectNationOpRequest {
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
export interface RejectNationOpResponse {
|
||||||
|
game: GameState;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdvisorRequest {
|
export interface AdvisorRequest {
|
||||||
/** Optional prompt to steer the brainstorm. */
|
/** Optional prompt to steer the brainstorm. */
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
@@ -192,6 +422,47 @@ export function nationColor(id: string): string {
|
|||||||
return `hsl(${hue} 58% 46%)`;
|
return `hsl(${hue} 58% 46%)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- No man's land (v0.22, #16 P3) — the map presentation contract ---
|
||||||
|
//
|
||||||
|
// An unowned region has two distinct looks, and the difference is NOT in the
|
||||||
|
// RegionMap (a key absent there is simply unowned): it is decided by the
|
||||||
|
// client from `GameState.unowned` (the keys released mid-game). The constants
|
||||||
|
// and the one decision function live here — shared and pure — so the
|
||||||
|
// client's choropleth is the only consumer and the color contract is testable
|
||||||
|
// from the server-side tests (shared/*.test.ts is not in the vitest glob).
|
||||||
|
|
||||||
|
/** The distinct, visible neutral grey for a **no man's land** region (released mid-game). Hue-less by construction, so it can never collide with a nation's `nationColor` (`hsl(h 58% 46%)`, always chromatic). */
|
||||||
|
export const NO_MANS_LAND_FILL = '#3f3f46';
|
||||||
|
/** The border of a no-man's-land region (brighter than a plain unowned one). */
|
||||||
|
export const NO_MANS_LAND_STROKE = 'rgba(255,255,255,0.25)';
|
||||||
|
/** The faint neutral fill for a region no nation owns that was never released mid-game (a preset never assigned it). */
|
||||||
|
export const UNOWNED_FILL = 'rgba(148,163,184,0.05)';
|
||||||
|
/** The border of a plain unowned region. */
|
||||||
|
export const UNOWNED_STROKE = 'rgba(255,255,255,0.14)';
|
||||||
|
/** The border of an owned region (brighter than unowned). */
|
||||||
|
export const OWNED_STROKE = 'rgba(255,255,255,0.30)';
|
||||||
|
|
||||||
|
/** The hover-tooltip sub-line for a region with no owning nation. */
|
||||||
|
export function unownedTooltipLabel(isNoMansLand: boolean): string {
|
||||||
|
return isNoMansLand ? "No man's land" : 'No owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fill + border for one sub-region, given its RegionMap entry
|
||||||
|
* (`undefined` = unowned) and whether its key is no man's land (released
|
||||||
|
* mid-game, i.e. in `GameState.unowned`). Pure + shared; see the section
|
||||||
|
* comment above for why the distinction is not encoded in the RegionMap.
|
||||||
|
*/
|
||||||
|
export function regionFillStyle(
|
||||||
|
owner: RegionOwner | undefined,
|
||||||
|
isNoMansLand: boolean,
|
||||||
|
): { fill: string; stroke: string } {
|
||||||
|
if (owner) return { fill: owner.color, stroke: OWNED_STROKE };
|
||||||
|
return isNoMansLand
|
||||||
|
? { fill: NO_MANS_LAND_FILL, stroke: NO_MANS_LAND_STROKE }
|
||||||
|
: { fill: UNOWNED_FILL, stroke: UNOWNED_STROKE };
|
||||||
|
}
|
||||||
|
|
||||||
/** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */
|
/** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */
|
||||||
export function makeEvent(
|
export function makeEvent(
|
||||||
spec: TurnEventSpec,
|
spec: TurnEventSpec,
|
||||||
|
|||||||
+101
-9
@@ -1,5 +1,9 @@
|
|||||||
// Domain types — the single source of truth shared by the frontend (src/), the
|
// Domain types — the single source of truth shared by the frontend (src/), the
|
||||||
// backend (server/), and the LLM prompt contract. No runtime dependencies here.
|
// backend (server/), and the LLM prompt contract. No runtime dependencies here.
|
||||||
|
// (The single `import type` below erases at compile time, so no runtime
|
||||||
|
// dependency on protocol.ts is introduced — see the GameState.pendingNationOps
|
||||||
|
// doc comment.)
|
||||||
|
import type { NationOp } from './protocol';
|
||||||
|
|
||||||
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
||||||
export interface NationStats {
|
export interface NationStats {
|
||||||
@@ -26,15 +30,10 @@ export const RELATION_STATUSES: RelationStatus[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single nation in the world. `territory` holds **sub-region ids** (e.g.
|
* A single nation in the world. `territory` holds **region keys** (see
|
||||||
* `FR-57`) — one entry per individually-ownable sub-region, at sub-region
|
* `public/region-keys.json`) — one per owned sub-region, so the choropleth can
|
||||||
* granularity rather than whole-country ISO_A3. The map colors each sub-region
|
* color individual sub-regions, not whole countries (v0.19, #16 P1). The map
|
||||||
* with this nation's stable color (see protocol.nationColor). At scenario start
|
* resolves each sub-region's owner by its region key (see protocol.mapFeatureOwner).
|
||||||
* 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 {
|
export interface Nation {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,6 +68,60 @@ export const EVENT_TYPES: EventType[] = [
|
|||||||
|
|
||||||
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
|
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
|
||||||
|
|
||||||
|
// --- Map markers (v0.27, #44) -------------------------------------------------
|
||||||
|
//
|
||||||
|
// Named point markers the LLM can place on the map (cities, factories, armies,
|
||||||
|
// ports, capitals, fleets, rocket launch sites, political/social gatherings).
|
||||||
|
// A marker is anchored to a **region key** (see `public/region-keys.json`): the
|
||||||
|
// engine positions it at that region's precomputed centroid (with a small
|
||||||
|
// deterministic offset) and its `ownerNationId` follows the region's current
|
||||||
|
// owner (recomputed on every turn; `null` when the region is unowned / no
|
||||||
|
// man's land). See protocol.ts for the LLM-facing `TurnResult.markers`
|
||||||
|
// contract and the `markerId` derivation.
|
||||||
|
|
||||||
|
/** The eight marker categories the model may place (v0.27, #44). */
|
||||||
|
export type MarkerType =
|
||||||
|
| 'city'
|
||||||
|
| 'factory'
|
||||||
|
| 'army'
|
||||||
|
| 'port'
|
||||||
|
| 'capital'
|
||||||
|
| 'fleet'
|
||||||
|
| 'rocket'
|
||||||
|
| 'gathering';
|
||||||
|
|
||||||
|
/** All marker types, in canonical order (used by the LLM contract + map layer). */
|
||||||
|
export const MARKER_TYPES: MarkerType[] = [
|
||||||
|
'city',
|
||||||
|
'factory',
|
||||||
|
'army',
|
||||||
|
'port',
|
||||||
|
'capital',
|
||||||
|
'fleet',
|
||||||
|
'rocket',
|
||||||
|
'gathering',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named point marker on the map (v0.27, #44). `id` is a stable,
|
||||||
|
* deterministic 4-character code derived from `type + name + regionKey`
|
||||||
|
* (see protocol.markerId) — re-adding the same marker is idempotent and
|
||||||
|
* removing by id is unambiguous. `lat`/`lon` are filled in by the engine from
|
||||||
|
* the region's centroid manifest (never by the model); `ownerNationId` is the
|
||||||
|
* marker's region's current owner (`null` when unowned) and is recomputed by
|
||||||
|
* the engine on every committed turn, so a marker always follows its region.
|
||||||
|
*/
|
||||||
|
export interface MapMarker {
|
||||||
|
id: string;
|
||||||
|
type: MarkerType;
|
||||||
|
name: string;
|
||||||
|
regionKey: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
ownerNationId: string | null;
|
||||||
|
placedTurn: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** A narrative event the simulation emitted on some turn. */
|
/** A narrative event the simulation emitted on some turn. */
|
||||||
export interface EventEntry {
|
export interface EventEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -125,6 +178,45 @@ export interface GameState {
|
|||||||
* in src/App.tsx. Starts at 1 on game creation.
|
* in src/App.tsx. Starts at 1 on game creation.
|
||||||
*/
|
*/
|
||||||
version: GameVersion;
|
version: GameVersion;
|
||||||
|
/**
|
||||||
|
* (v0.22, #16 P3) Region keys that became **unowned** — "no man's land" —
|
||||||
|
* during play: a region that was owned and was released to no owner by a
|
||||||
|
* committed turn (the LLM narrates the cause in that turn's events). This is
|
||||||
|
* deliberately distinct from the many regions a preset never assigns, which
|
||||||
|
* are also unowned but were never *released* mid-game; only the keys in this
|
||||||
|
* list render as the distinct visible no-man's-land grey (see
|
||||||
|
* protocol.regionFillStyle). A region leaves the list when any nation
|
||||||
|
* re-claims it (an ordinary `gain`). Always `[]` on a fresh game — no
|
||||||
|
* man's land can only arise mid-game. Rows saved before v0.22 load with
|
||||||
|
* this defaulted to `[]` (see server/db.ts).
|
||||||
|
*/
|
||||||
|
unowned: string[];
|
||||||
|
/**
|
||||||
|
* (v0.23, #16 P4a; v0.24, #16 P4b) Nation-management ops the LLM has
|
||||||
|
* proposed but the player has not yet resolved: a *pending proposal* list,
|
||||||
|
* as opposed to `pending` (the current round's queued player actions). A
|
||||||
|
* turn NEVER applies one — `applyTurnResult` appends the turn's
|
||||||
|
* `nationOps` to this list, and each proposal survives across turns until
|
||||||
|
* the player resolves it: confirming applies it (a rename changes
|
||||||
|
* `Nation.name` only; a split partitions the nation's region-key territory
|
||||||
|
* into the new nations' territories; a merge folds the inputs' territory
|
||||||
|
* into the survivor) and removes it; rejecting removes it without
|
||||||
|
* applying. Always `[]` on a fresh game; rows saved before v0.23 load with
|
||||||
|
* this defaulted to `[]` (see server/db.ts). (The `NationOp` type lives in
|
||||||
|
* protocol.ts; the import there is type-only, so this file still has no
|
||||||
|
* runtime dependencies.)
|
||||||
|
*/
|
||||||
|
pendingNationOps: NationOp[];
|
||||||
|
/**
|
||||||
|
* (v0.27, #44) Named point markers on the map: the LLM places them (via a
|
||||||
|
* turn's `markers.add`) and removes them (`markers.remove`, by id). The
|
||||||
|
* engine positions each at its region's centroid (deterministic small
|
||||||
|
* offset) and recomputes `ownerNationId` from the region's current owner on
|
||||||
|
* EVERY committed turn (a released region → `null`). Always `[]` on a fresh
|
||||||
|
* game; rows saved before v0.27 load with this defaulted to `[]` (see
|
||||||
|
* server/db.ts) — additive, no data wipe. Capped at protocol.MAX_MARKERS.
|
||||||
|
*/
|
||||||
|
markers: MapMarker[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+61
-5
@@ -177,6 +177,10 @@ export default function App() {
|
|||||||
// gate the turn flow ("Jump forward" stays enabled while the advisor thinks) —
|
// gate the turn flow ("Jump forward" stays enabled while the advisor thinks) —
|
||||||
// it only gates the advisor UI (the "Ask advisor" button + its hint).
|
// it only gates the advisor UI (the "Ask advisor" button + its hint).
|
||||||
const [advisorBusy, setAdvisorBusy] = useState(false);
|
const [advisorBusy, setAdvisorBusy] = useState(false);
|
||||||
|
// Separate again: a pending nation-op confirm/reject is its own (fast,
|
||||||
|
// non-LLM) request. It gates only the nation-op dialog controls, never the
|
||||||
|
// turn flow — "Jump forward" stays usable while it is in flight.
|
||||||
|
const [opBusy, setOpBusy] = useState(false);
|
||||||
// The id of the game the user is/was last in (mirrors localStorage) and the
|
// The id of the game the user is/was last in (mirrors localStorage) and the
|
||||||
// list of saved games (newest first) shown on the home screen.
|
// list of saved games (newest first) shown on the home screen.
|
||||||
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
||||||
@@ -401,6 +405,44 @@ export default function App() {
|
|||||||
[game, applyState],
|
[game, applyState],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Apply a pending nation op the model proposed (confirm; `edits.name`
|
||||||
|
// overrides a rename/merge name, `edits.playerPart` picks the player's
|
||||||
|
// successor part in a split). Fast, non-LLM — gated by `opBusy`, not `busy`.
|
||||||
|
// The version-bumped state comes back in the response and the SSE `state` event.
|
||||||
|
const runConfirmNationOp = useCallback(
|
||||||
|
async (index: number, edits?: { name?: string; playerPart?: number }) => {
|
||||||
|
if (!game) return;
|
||||||
|
setOpBusy(true);
|
||||||
|
try {
|
||||||
|
const { game: g } = await api.confirmNationOp(game.id, index, edits);
|
||||||
|
applyState(g);
|
||||||
|
toast.success('Nation change applied');
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
setOpBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[game, applyState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop a pending nation op (reject / dismiss). Fast, non-LLM — `opBusy`.
|
||||||
|
const runRejectNationOp = useCallback(
|
||||||
|
async (index: number) => {
|
||||||
|
if (!game) return;
|
||||||
|
setOpBusy(true);
|
||||||
|
try {
|
||||||
|
const { game: g } = await api.rejectNationOp(game.id, index);
|
||||||
|
applyState(g);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
setOpBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[game, applyState],
|
||||||
|
);
|
||||||
|
|
||||||
// Jump forward: the single LLM call that commits the pending round. Only this
|
// Jump forward: the single LLM call that commits the pending round. Only this
|
||||||
// sets `busy` (the adds above are fast and non-LLM).
|
// sets `busy` (the adds above are fast and non-LLM).
|
||||||
const runJump = useCallback(async () => {
|
const runJump = useCallback(async () => {
|
||||||
@@ -507,12 +549,15 @@ export default function App() {
|
|||||||
regions={regions}
|
regions={regions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
advisorBusy={advisorBusy}
|
advisorBusy={advisorBusy}
|
||||||
|
opBusy={opBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={runAddAction}
|
onAddAction={runAddAction}
|
||||||
onAddDiplomacy={runAddDiplomacy}
|
onAddDiplomacy={runAddDiplomacy}
|
||||||
onRemoveQueueItem={runRemoveQueueItem}
|
onRemoveQueueItem={runRemoveQueueItem}
|
||||||
onAdvisor={askAdvisor}
|
onAdvisor={askAdvisor}
|
||||||
onJump={runJump}
|
onJump={runJump}
|
||||||
|
onConfirmNationOp={runConfirmNationOp}
|
||||||
|
onRejectNationOp={runRejectNationOp}
|
||||||
onHome={goHome}
|
onHome={goHome}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -551,12 +596,16 @@ interface GameScreenProps {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
|
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
|
||||||
advisorBusy: boolean;
|
advisorBusy: boolean;
|
||||||
|
/** A nation-op confirm/reject is in flight — drives the dialog controls only. */
|
||||||
|
opBusy: boolean;
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
onAddAction: (a: string) => void;
|
onAddAction: (a: string) => void;
|
||||||
onAddDiplomacy: (to: string, m: string) => void;
|
onAddDiplomacy: (to: string, m: string) => void;
|
||||||
onRemoveQueueItem: (index: number) => void;
|
onRemoveQueueItem: (index: number) => void;
|
||||||
onAdvisor: (p?: string) => void;
|
onAdvisor: (p?: string) => void;
|
||||||
onJump: () => void;
|
onJump: () => void;
|
||||||
|
onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
|
||||||
|
onRejectNationOp: (index: number) => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,24 +628,26 @@ function GameScreen({
|
|||||||
regions,
|
regions,
|
||||||
busy,
|
busy,
|
||||||
advisorBusy,
|
advisorBusy,
|
||||||
|
opBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
onRemoveQueueItem,
|
onRemoveQueueItem,
|
||||||
onAdvisor,
|
onAdvisor,
|
||||||
onJump,
|
onJump,
|
||||||
|
onConfirmNationOp,
|
||||||
|
onRejectNationOp,
|
||||||
onHome,
|
onHome,
|
||||||
}: GameScreenProps) {
|
}: GameScreenProps) {
|
||||||
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
||||||
const [viewingId, setViewingId] = useState(game.playerNationId);
|
const [viewingId, setViewingId] = useState(game.playerNationId);
|
||||||
// When the dossier target was set by a map click, remember which sub-region
|
// When the dossier target was set by a map click, remember which sub-region
|
||||||
// was clicked, for a small label in the dossier. The label shows only the
|
// was clicked, for a small label in the dossier. The label shows only the
|
||||||
// region's name — the static parent-country code (which never updates on a
|
// region's name. Cleared when the user picks a nation via the manual Inspect
|
||||||
// transfer) is no longer displayed, though the info object still carries it.
|
// select.
|
||||||
// Cleared when the user picks a nation via the manual Inspect select.
|
const [selectedRegion, setSelectedRegion] = useState<{ name?: string } | null>(null);
|
||||||
const [selectedRegion, setSelectedRegion] = useState<{ parent?: string; name?: string } | null>(null);
|
|
||||||
const focusNation = useCallback(
|
const focusNation = useCallback(
|
||||||
(id: string, info?: { parent?: string; name?: string }) => {
|
(id: string, info?: { name?: string }) => {
|
||||||
setViewingId(id);
|
setViewingId(id);
|
||||||
setSelectedRegion(info ?? null);
|
setSelectedRegion(info ?? null);
|
||||||
},
|
},
|
||||||
@@ -654,6 +705,8 @@ function GameScreen({
|
|||||||
playerNationId={game.playerNationId}
|
playerNationId={game.playerNationId}
|
||||||
nations={game.nations}
|
nations={game.nations}
|
||||||
presetId={game.presetId}
|
presetId={game.presetId}
|
||||||
|
unowned={game.unowned ?? []}
|
||||||
|
markers={game.markers ?? []}
|
||||||
onFocusNation={focusNation}
|
onFocusNation={focusNation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -835,12 +888,15 @@ function GameScreen({
|
|||||||
game={game}
|
game={game}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
advisorBusy={advisorBusy}
|
advisorBusy={advisorBusy}
|
||||||
|
opBusy={opBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={onAddAction}
|
onAddAction={onAddAction}
|
||||||
onAddDiplomacy={onAddDiplomacy}
|
onAddDiplomacy={onAddDiplomacy}
|
||||||
onRemoveQueueItem={onRemoveQueueItem}
|
onRemoveQueueItem={onRemoveQueueItem}
|
||||||
onAdvisor={onAdvisor}
|
onAdvisor={onAdvisor}
|
||||||
onJump={onJump}
|
onJump={onJump}
|
||||||
|
onConfirmNationOp={onConfirmNationOp}
|
||||||
|
onRejectNationOp={onRejectNationOp}
|
||||||
inWindow
|
inWindow
|
||||||
/>
|
/>
|
||||||
</FloatingWindow>
|
</FloatingWindow>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loader2, MessageSquare, Radar, Trash2, Zap } from 'lucide-react';
|
import { Loader2, MessageSquare, PenLine, Radar, Trash2, Zap } from 'lucide-react';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
@@ -15,6 +15,7 @@ import { Input } from './ui/input';
|
|||||||
import { Textarea } from './ui/textarea';
|
import { Textarea } from './ui/textarea';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||||
|
import NationOpDialog from './NationOpDialog';
|
||||||
import type { GameState, RoundItem } from '@shared/types';
|
import type { GameState, RoundItem } from '@shared/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,6 +28,11 @@ interface Props {
|
|||||||
* never disable "Jump forward" or the rest of the turn flow.
|
* never disable "Jump forward" or the rest of the turn flow.
|
||||||
*/
|
*/
|
||||||
advisorBusy: boolean;
|
advisorBusy: boolean;
|
||||||
|
/**
|
||||||
|
* A pending nation-op confirm/reject request is in flight. Distinct from
|
||||||
|
* `busy`: it gates only the nation-op dialog controls, never the turn flow.
|
||||||
|
*/
|
||||||
|
opBusy: boolean;
|
||||||
/** Advisor brainstorm suggestions from the last request. */
|
/** Advisor brainstorm suggestions from the last request. */
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
/** Queue a free-text action onto the pending round (no turn advance). */
|
/** Queue a free-text action onto the pending round (no turn advance). */
|
||||||
@@ -38,6 +44,14 @@ interface Props {
|
|||||||
onAdvisor: (prompt?: string) => void;
|
onAdvisor: (prompt?: string) => void;
|
||||||
/** Commit the whole pending round in a single LLM turn. */
|
/** Commit the whole pending round in a single LLM turn. */
|
||||||
onJump: () => void;
|
onJump: () => void;
|
||||||
|
/**
|
||||||
|
* Apply a pending nation op: `edits.name` overrides a rename's target name
|
||||||
|
* (or a merge's name); `edits.playerPart` picks the player's successor part
|
||||||
|
* in a split (default 0).
|
||||||
|
*/
|
||||||
|
onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
|
||||||
|
/** Drop (reject) a pending nation op by its index. */
|
||||||
|
onRejectNationOp: (index: number) => void;
|
||||||
/**
|
/**
|
||||||
* Render without the outer card chrome (the surrounding floating window
|
* Render without the outer card chrome (the surrounding floating window
|
||||||
* provides the border and the "Your move" title). Same content, same testids.
|
* provides the border and the "Your move" title). Same content, same testids.
|
||||||
@@ -66,12 +80,15 @@ export default function ActionPanel({
|
|||||||
game,
|
game,
|
||||||
busy,
|
busy,
|
||||||
advisorBusy,
|
advisorBusy,
|
||||||
|
opBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
onRemoveQueueItem,
|
onRemoveQueueItem,
|
||||||
onAdvisor,
|
onAdvisor,
|
||||||
onJump,
|
onJump,
|
||||||
|
onConfirmNationOp,
|
||||||
|
onRejectNationOp,
|
||||||
inWindow = false,
|
inWindow = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [tab, setTab] = useState('action');
|
const [tab, setTab] = useState('action');
|
||||||
@@ -81,6 +98,8 @@ export default function ActionPanel({
|
|||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
// Index of the queued item the user is about to remove, or null (dialog closed).
|
// Index of the queued item the user is about to remove, or null (dialog closed).
|
||||||
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
|
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
|
||||||
|
// Whether the nation-op review dialog is open.
|
||||||
|
const [opDialogOpen, setOpDialogOpen] = useState(false);
|
||||||
|
|
||||||
const others = Object.entries(game.nations)
|
const others = Object.entries(game.nations)
|
||||||
.filter(([id]) => id !== game.playerNationId)
|
.filter(([id]) => id !== game.playerNationId)
|
||||||
@@ -108,6 +127,7 @@ export default function ActionPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pending = game.pending;
|
const pending = game.pending;
|
||||||
|
const ops = game.pendingNationOps;
|
||||||
// The item the confirmation dialog is about (re-derived from the live list,
|
// The item the confirmation dialog is about (re-derived from the live list,
|
||||||
// so if the queue changes while the dialog is open it tracks the current
|
// so if the queue changes while the dialog is open it tracks the current
|
||||||
// item at that index — or the dialog closes if the index went out of range).
|
// item at that index — or the dialog closes if the index went out of range).
|
||||||
@@ -325,6 +345,31 @@ export default function ActionPanel({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Pending nation ops proposed by the model — open to confirm or reject. */}
|
||||||
|
{ops.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpDialogOpen(true)}
|
||||||
|
disabled={busy}
|
||||||
|
data-testid="nation-op-trigger"
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2.5 text-sm hover:bg-amber-500/15"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-medium text-amber-300">
|
||||||
|
<PenLine className="size-4" />
|
||||||
|
{ops.length} nation {ops.length === 1 ? 'proposal' : 'proposals'} — review
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-amber-300/70">confirm or reject</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<NationOpDialog
|
||||||
|
game={game}
|
||||||
|
open={opDialogOpen}
|
||||||
|
onOpenChange={setOpDialogOpen}
|
||||||
|
opBusy={opBusy}
|
||||||
|
onConfirm={onConfirmNationOp}
|
||||||
|
onReject={onRejectNationOp}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
||||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
|
||||||
{queuedLabel}
|
{queuedLabel}
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, Loader2, PenLine, X } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import type { GameState } from '@shared/types';
|
||||||
|
import type { NationOp, RenameOp } from '@shared/protocol';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: GameState;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** A confirm/reject request is in flight — disable the per-op controls. */
|
||||||
|
opBusy: boolean;
|
||||||
|
/**
|
||||||
|
* Apply a pending op: `edits.name` overrides a rename's target name (or the
|
||||||
|
* merged name for a merge); `edits.playerPart` picks which new part the
|
||||||
|
* player's nation becomes in a split (default 0).
|
||||||
|
*/
|
||||||
|
onConfirm: (index: number, edits?: { name?: string; playerPart?: number }) => void;
|
||||||
|
/** Drop a pending op (reject / dismiss). */
|
||||||
|
onReject: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short, display-safe description of a pending nation op. */
|
||||||
|
function describeOp(op: NationOp, name: (ref: string) => string): string {
|
||||||
|
if (op.kind === 'rename') return `Rename ${name(op.nationRef)} to “${op.to}”`;
|
||||||
|
if (op.kind === 'split')
|
||||||
|
return `Split ${name(op.nationRef)} into ${op.parts.length} nations (${op.parts
|
||||||
|
.map((p) => p.name)
|
||||||
|
.join(', ')})`;
|
||||||
|
return `Merge ${op.nationRefs.map(name).join(' + ')} into “${op.into}”`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviews the nation ops the LLM has proposed (stored in `pendingNationOps`)
|
||||||
|
* and lets the player apply ("LLM proposes, player confirms") or drop each one:
|
||||||
|
* a rename shows the proposed name in an editable field; a split lists the
|
||||||
|
* proposed parts (with the number of regions each is assigned) and, when the
|
||||||
|
* split parent is the player's own nation, radio buttons to pick which new
|
||||||
|
* part the player leads; a merge shows the input nations (the survivor
|
||||||
|
* highlighted) and an editable name for the merged state (default `into`).
|
||||||
|
*/
|
||||||
|
export default function NationOpDialog({
|
||||||
|
game,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
opBusy,
|
||||||
|
onConfirm,
|
||||||
|
onReject,
|
||||||
|
}: Props) {
|
||||||
|
const ops = game.pendingNationOps;
|
||||||
|
const name = (ref: string) => game.nations[ref]?.name ?? ref;
|
||||||
|
// Resolve a nation ref the way the server does (id first, then a unique
|
||||||
|
// case-insensitive name; null when unknown/ambiguous) — UI hints only, the
|
||||||
|
// server stays authoritative.
|
||||||
|
const resolveRef = (ref: string): string | null => {
|
||||||
|
const r = (ref ?? '').trim();
|
||||||
|
if (!r) return null;
|
||||||
|
if (game.nations[r]) return r;
|
||||||
|
const matches = Object.values(game.nations).filter(
|
||||||
|
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
|
||||||
|
);
|
||||||
|
return matches.length === 1 ? matches[0].id : null;
|
||||||
|
};
|
||||||
|
// The player's edits, keyed by pending-op index: the rename/merge target
|
||||||
|
// name (an unset entry means "use the model's proposed name"), and which
|
||||||
|
// split part the player succeeds to (preselected 0 — the radio is
|
||||||
|
// "required" and always has a valid answer).
|
||||||
|
const [edits, setEdits] = useState<Record<number, string>>({});
|
||||||
|
const [playerParts, setPlayerParts] = useState<Record<number, number>>({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg" data-testid="nation-op-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<PenLine className="size-4 text-primary" />
|
||||||
|
Nation proposals
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The model proposed changes to nations. Confirm to apply them (you can edit a
|
||||||
|
name and choose your successor state in a split) or reject to leave the world
|
||||||
|
as it is.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{ops.length === 0 ? (
|
||||||
|
<p className="py-2 text-sm text-muted-foreground" data-testid="nation-op-empty">
|
||||||
|
Nothing pending — the model hasn’t proposed any nation changes.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-[50vh] space-y-2.5 overflow-y-auto pr-1" data-testid="nation-op-list">
|
||||||
|
{ops.map((op, i) => {
|
||||||
|
const isRename = op.kind === 'rename';
|
||||||
|
const isSplit = op.kind === 'split';
|
||||||
|
const isMerge = op.kind === 'merge';
|
||||||
|
const finalName = isRename ? (edits[i] ?? (op as RenameOp).to).trim() : '';
|
||||||
|
// Split: is the parent the player's own nation? (the successor radio
|
||||||
|
// is offered only in that case — the engine ignores playerPart otherwise)
|
||||||
|
const splitParentIsPlayer =
|
||||||
|
isSplit && resolveRef((op as { nationRef: string }).nationRef) === game.playerNationId;
|
||||||
|
const playerPart = playerParts[i] ?? 0;
|
||||||
|
// Merge: the survivor (highlighted) and whether the proposal is
|
||||||
|
// even valid (2+ distinct inputs; the survivor among them).
|
||||||
|
const survivorId = isMerge ? resolveRef((op as { survivingRef: string }).survivingRef) : null;
|
||||||
|
const inputIds = isMerge
|
||||||
|
? new Set(
|
||||||
|
(op as { nationRefs: string[] }).nationRefs
|
||||||
|
.map(resolveRef)
|
||||||
|
.filter((x): x is string => x !== null),
|
||||||
|
)
|
||||||
|
: new Set<string>();
|
||||||
|
const mergeValid =
|
||||||
|
!isMerge || (inputIds.size >= 2 && (survivorId !== null && inputIds.has(survivorId)));
|
||||||
|
const mergeName = isMerge ? (edits[i] ?? (op as { into: string }).into).trim() : '';
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
if (isRename) onConfirm(i, finalName ? { name: finalName } : undefined);
|
||||||
|
else if (isMerge) onConfirm(i, mergeName ? { name: mergeName } : undefined);
|
||||||
|
else onConfirm(i, splitParentIsPlayer ? { playerPart } : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${i}-${op.kind}`}
|
||||||
|
className="rounded-lg border border-border/70 bg-card/50 p-3"
|
||||||
|
data-testid="nation-op-item"
|
||||||
|
data-kind={op.kind}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-start gap-2">
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-[11px] capitalize">
|
||||||
|
{op.kind}
|
||||||
|
</Badge>
|
||||||
|
<span className="min-w-0 flex-1 text-sm leading-snug">
|
||||||
|
{describeOp(op, name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRename ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<Input
|
||||||
|
value={edits[i] ?? (op as RenameOp).to}
|
||||||
|
onChange={(e) => setEdits((p) => ({ ...p, [i]: e.target.value }))}
|
||||||
|
placeholder="New name…"
|
||||||
|
data-testid="nation-op-name-input"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(i)}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="nation-op-reject"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={opBusy}
|
||||||
|
data-testid="nation-op-confirm"
|
||||||
|
>
|
||||||
|
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isSplit ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(op as { parts: { name: string; territory?: string[] }[] }).parts.map((p, pi) => (
|
||||||
|
<div
|
||||||
|
key={pi}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
data-testid="nation-op-part"
|
||||||
|
data-part={pi}
|
||||||
|
>
|
||||||
|
{splitParentIsPlayer ? (
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`player-part-${i}`}
|
||||||
|
checked={playerPart === pi}
|
||||||
|
onChange={() => setPlayerParts((prev) => ({ ...prev, [i]: pi }))}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="size-3.5 shrink-0 accent-amber-400"
|
||||||
|
aria-label={`Make ${p.name} the player's successor state`}
|
||||||
|
data-testid="nation-op-player-part"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{p.name}</span>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{p.territory?.length ?? 0} region{(p.territory?.length ?? 0) === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{splitParentIsPlayer && (
|
||||||
|
<p className="text-[11px] text-muted-foreground" data-testid="nation-op-split-hint">
|
||||||
|
Choose which new state you will lead after the split.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(op as { parts: unknown[] }).parts.length < 2 && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
This split names fewer than two parts — confirming will dismiss it
|
||||||
|
without changing the world.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(i)}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="nation-op-reject"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={opBusy}
|
||||||
|
data-testid="nation-op-confirm"
|
||||||
|
>
|
||||||
|
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(op as { nationRefs: string[] }).nationRefs.map((ref, ri) => {
|
||||||
|
const isSurvivor = resolveRef(ref) === survivorId;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={ri}
|
||||||
|
variant={isSurvivor ? 'default' : 'secondary'}
|
||||||
|
className="text-[11px]"
|
||||||
|
data-testid="nation-op-input"
|
||||||
|
>
|
||||||
|
{name(ref)}
|
||||||
|
{isSurvivor ? ' (survivor)' : ''}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={edits[i] ?? (op as { into: string }).into}
|
||||||
|
onChange={(e) => setEdits((p) => ({ ...p, [i]: e.target.value }))}
|
||||||
|
placeholder="New name…"
|
||||||
|
data-testid="nation-op-name-input"
|
||||||
|
/>
|
||||||
|
{!mergeValid && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
This merge is not valid as proposed — confirming will dismiss it
|
||||||
|
without changing the world.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(i)}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="nation-op-reject"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={opBusy}
|
||||||
|
data-testid="nation-op-confirm"
|
||||||
|
>
|
||||||
|
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||||
import StatBar from './StatBar';
|
import StatBar from './StatBar';
|
||||||
import { nationColor } from '@shared/protocol';
|
import { nationColor } from '@shared/protocol';
|
||||||
import { RELATION_COLORS } from '@/lib/colors';
|
import { RELATION_COLORS } from '@/lib/colors';
|
||||||
|
import { keyToName } from '@/lib/regions';
|
||||||
import type { Nation, RelationStatus } from '@shared/types';
|
import type { Nation, RelationStatus } from '@shared/types';
|
||||||
|
|
||||||
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
|
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
|
||||||
@@ -38,6 +40,23 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals
|
|||||||
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
||||||
const regions = nation.territory;
|
const regions = nation.territory;
|
||||||
|
|
||||||
|
// Human region names for the territory chips (region key -> name), loaded once
|
||||||
|
// (cached in @/lib/regions). A key with no known name falls back to the raw key.
|
||||||
|
const [keyNames, setKeyNames] = useState<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
keyToName()
|
||||||
|
.then((m) => {
|
||||||
|
if (!cancelled) setKeyNames(m);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* names are cosmetic — a failed fetch just leaves the raw keys */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const identity = (
|
const identity = (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
@@ -95,15 +114,20 @@ export default function NationPanel({ nation, nations, isPlayer, inWindow = fals
|
|||||||
</h3>
|
</h3>
|
||||||
{regions.length > 0 ? (
|
{regions.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{regions.map((code) => (
|
{regions.map((key) => {
|
||||||
|
const label = keyNames[key] ?? key;
|
||||||
|
return (
|
||||||
<span
|
<span
|
||||||
key={code}
|
key={key}
|
||||||
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
|
className="rounded border px-1.5 py-0.5 text-xs"
|
||||||
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
|
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
|
||||||
|
title={key}
|
||||||
|
data-region-key={key}
|
||||||
>
|
>
|
||||||
{code}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No territory.</p>
|
<p className="text-sm text-muted-foreground">No territory.</p>
|
||||||
|
|||||||
+521
-116
@@ -8,16 +8,23 @@ import 'maplibre-gl/dist/maplibre-gl.css';
|
|||||||
// Point it at a Vite-emitted copy of the worker module so it loads in prod.
|
// 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';
|
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
||||||
maplibregl.setWorkerUrl(workerUrl);
|
maplibregl.setWorkerUrl(workerUrl);
|
||||||
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
|
import {
|
||||||
import type { Nation } from '@shared/types';
|
mapFeatureOwner,
|
||||||
|
MARKER_TYPE_INFO,
|
||||||
|
nationColor,
|
||||||
|
regionFillStyle,
|
||||||
|
unownedTooltipLabel,
|
||||||
|
type RegionMap,
|
||||||
|
type RegionOwner,
|
||||||
|
} from '@shared/protocol';
|
||||||
|
import { MARKER_TYPES, type MapMarker, type Nation } from '@shared/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { idToKey } from '@/lib/regions';
|
||||||
|
|
||||||
// Minimal GeoJSON types (avoids a hard @types/geojson dependency). The choropleth
|
// 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
|
// source is the sub-national `regions.geo.json`: each feature is a sub-region with
|
||||||
// a stable top-level `id` (ISO 3166-2 style, e.g. `FR-57`, `US-AK`) and
|
// a top-level `id` of the form `<ISO_A3>-<nn>` (e.g. `FRA-03`) and
|
||||||
// `properties = { name, parent }` where `parent` is the parent country's ISO_A3.
|
// `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 {
|
interface GeoFeature {
|
||||||
type: 'Feature';
|
type: 'Feature';
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
@@ -40,8 +47,16 @@ type GeoSource = maplibregl.GeoJSONSource;
|
|||||||
// looked black, and `styledata` was gated on fetching a remote document. An
|
// looked black, and `styledata` was gated on fetching a remote document. An
|
||||||
// inline object loads instantly, so `isStyleLoaded()` / `styledata` fire
|
// inline object loads instantly, so `isStyleLoaded()` / `styledata` fire
|
||||||
// immediately and the choropleth is guaranteed to paint. (The OFM `ne2sr`
|
// immediately and the choropleth is guaranteed to paint. (The OFM `ne2sr`
|
||||||
// raster has no vector tiles, so no glyphs endpoint is needed — we don't add
|
// raster has no vector tiles, so the basemap itself needs no label layers —
|
||||||
// label layers; the choropleth provides the borders.)
|
// the choropleth provides the borders.)
|
||||||
|
//
|
||||||
|
// No `glyphs` URL: the map needs no MapLibre *text* rendering. The basemap is
|
||||||
|
// raster (its labels are baked into the PNG tiles), and the LLM-placed map
|
||||||
|
// markers (v0.27, #44) are drawn as canvas-generated icon images in a symbol
|
||||||
|
// layer — fully self-contained, so they render offline and deterministically
|
||||||
|
// without depending on any external font/glyph endpoint (the OpenFreeMap
|
||||||
|
// `fonts/` path is a dead nginx 404, and the basemap's apparent labels come
|
||||||
|
// from the raster, not from glyphs).
|
||||||
const NE2SR_TILES = ['https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png'];
|
const NE2SR_TILES = ['https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png'];
|
||||||
|
|
||||||
const INLINE_STYLE: maplibregl.StyleSpecification = {
|
const INLINE_STYLE: maplibregl.StyleSpecification = {
|
||||||
@@ -83,6 +98,8 @@ const PLAYER_LINE = 'player-line';
|
|||||||
const FLASH_LINE = 'flash-line';
|
const FLASH_LINE = 'flash-line';
|
||||||
const HOVER_FILL = 'hover-fill';
|
const HOVER_FILL = 'hover-fill';
|
||||||
const HOVER_LINE = 'hover-line';
|
const HOVER_LINE = 'hover-line';
|
||||||
|
const MARKERS_SOURCE = 'markers';
|
||||||
|
const MARKER_SYMBOL = 'markers-symbol';
|
||||||
|
|
||||||
/** Sensible per-scenario initial camera: Europe for the WWII scenarios, wider for the global ones. */
|
/** Sensible per-scenario initial camera: Europe for the WWII scenarios, wider for the global ones. */
|
||||||
function cameraForPreset(presetId: string): { center: [number, number]; zoom: number } {
|
function cameraForPreset(presetId: string): { center: [number, number]; zoom: number } {
|
||||||
@@ -103,51 +120,65 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
|
* Stamp the current ownership + colors onto a clone of the sub-national base geo.
|
||||||
* Since v0.19 ownership and the LLM live at **sub-region** granularity: a nation
|
* Ownership is at **region-key** granularity (v0.19, #16 P1): each sub-region has
|
||||||
* owns individual sub-region ids, so each sub-region carries the color of the
|
* its own region key (from `public/region-keys.json`, looked up by the feature's
|
||||||
* nation that owns that exact sub-region (a country can now be split between
|
* stable id) and the RegionMap is keyed by that region key, so a single
|
||||||
* nations, and a sub-region in no nation's territory renders grey). Each
|
* sub-region can change owner while its neighbours keep theirs. Each feature
|
||||||
* sub-region therefore carries:
|
* therefore carries:
|
||||||
*
|
*
|
||||||
* - `regionId` – its own sub-region id (e.g. `FR-57`), kept in a property that
|
* - `regionId` – its own sub-region id (e.g. `FRA-03`), kept in a property that
|
||||||
* survives into the features MapLibre hands back on click/hover
|
* survives into the features MapLibre hands back on click/hover
|
||||||
* (the top-level `id` is repacked into a numeric index, #6) —
|
* (the top-level `id` is repacked into a numeric index, #6);
|
||||||
* and the **RegionMap key** the owner is resolved through;
|
* - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key;
|
||||||
* - `parent` – the parent country's ISO_A3 (e.g. `FRA`); a static geographic
|
* - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`);
|
||||||
* fact shown in the dossier label, **not** used for ownership;
|
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned region);
|
||||||
* - `fill` – the owning nation's stable color (from `RegionMap[regionId]`);
|
* - `stroke` – a per-border alpha (owned brightest, no man's land next,
|
||||||
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned sub-region);
|
* plain-unowned faintest);
|
||||||
* - `stroke` – a per-border alpha (owned brighter than unowned);
|
|
||||||
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
|
* - `isPlayer` – 1 when the owning nation is the player (drives the amber
|
||||||
* player-highlight border).
|
* player-highlight border).
|
||||||
*
|
*
|
||||||
* Crucially, the owner lookup keys the RegionMap by the feature's **own
|
* Crucially, the owner lookup keys the RegionMap by the stamped
|
||||||
* sub-region id** (as `properties.regionId` at interaction time, via
|
* `properties.regionKey`, **never** by the sub-region id or the repacked
|
||||||
* `mapFeatureOwner` in @shared/protocol) — **never** by the repacked top-level
|
* top-level `id` — the RegionMap has no `FRA-03` entries, only region keys.
|
||||||
* `id` (a MapLibre numeric index, #6) and **never** by the parent country code.
|
*
|
||||||
|
* (v0.22, #16 P3) Unowned regions have two looks, decided here (the
|
||||||
|
* RegionMap simply has no entry for them): a key in `unownedSet` — released to
|
||||||
|
* no man's land mid-game (`GameState.unowned`) — gets the distinct visible grey
|
||||||
|
* (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint
|
||||||
|
* preset-unassigned look. Both come from the shared `regionFillStyle` contract.
|
||||||
*/
|
*/
|
||||||
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
|
function applyRegions(
|
||||||
|
geo: FeatureCollection,
|
||||||
|
regions: RegionMap,
|
||||||
|
playerNationId: string,
|
||||||
|
idKey: Record<string, string>,
|
||||||
|
unownedSet: ReadonlySet<string>,
|
||||||
|
): FeatureCollection {
|
||||||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||||||
for (const f of out.features) {
|
for (const f of out.features) {
|
||||||
f.properties = f.properties ?? {};
|
f.properties = f.properties ?? {};
|
||||||
// The sub-region's own id (e.g. `FR-57`) — stamped into a property that
|
// The sub-region's own id (e.g. `FRA-03`) — stamped into a property that
|
||||||
// survives into the rendered features (the top-level `id` gets repacked).
|
// survives into the rendered features (the top-level `id` gets repacked).
|
||||||
const regionId = f.id == null ? '' : String(f.id);
|
const subId = f.id == null ? '' : String(f.id);
|
||||||
f.properties.regionId = regionId;
|
f.properties.regionId = subId;
|
||||||
// The parent country's ISO_A3 (e.g. `FRA`) — a static geographic fact used
|
// Resolve this feature's **region key** from the manifest (id -> key) and
|
||||||
// for the dossier label only; ownership is resolved by `regionId`.
|
// key the RegionMap by it — never the sub-region id or the repacked id.
|
||||||
const parent = typeof f.properties.parent === 'string' ? f.properties.parent : '';
|
const key = idKey[subId] ?? '';
|
||||||
f.properties.parent = parent;
|
f.properties.regionKey = key;
|
||||||
const r = regionId ? regions[regionId] : undefined;
|
const r = key ? regions[key] : undefined;
|
||||||
if (r) {
|
if (r) {
|
||||||
f.properties.fill = r.color;
|
const style = regionFillStyle(r, false);
|
||||||
|
f.properties.fill = style.fill;
|
||||||
|
f.properties.stroke = style.stroke;
|
||||||
f.properties.owner = r.owner;
|
f.properties.owner = r.owner;
|
||||||
f.properties.ownerId = r.ownerId;
|
f.properties.ownerId = r.ownerId;
|
||||||
f.properties.stroke = 'rgba(255,255,255,0.30)';
|
|
||||||
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
|
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
|
||||||
} else {
|
} else {
|
||||||
f.properties.fill = 'rgba(148,163,184,0.05)';
|
// Ownerless: visible grey only for keys released mid-game (no man's
|
||||||
f.properties.stroke = 'rgba(255,255,255,0.14)';
|
// land); a preset that never assigned this sub-region stays faint.
|
||||||
|
const style = regionFillStyle(undefined, key !== '' && unownedSet.has(key));
|
||||||
|
f.properties.fill = style.fill;
|
||||||
|
f.properties.stroke = style.stroke;
|
||||||
f.properties.owner = '';
|
f.properties.owner = '';
|
||||||
f.properties.ownerId = '';
|
f.properties.ownerId = '';
|
||||||
f.properties.isPlayer = 0;
|
f.properties.isPlayer = 0;
|
||||||
@@ -157,18 +188,205 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a FeatureCollection of just the sub-regions whose **own sub-region id**
|
* Build a FeatureCollection of just the sub-regions whose **region key** is in
|
||||||
* is in `regionIds` (cloned). Used by the territory-change flash: since v0.19
|
* `keys` (cloned). Used by the territory-change flash: a turn can change
|
||||||
* a turn changes individual sub-regions (the RegionMap is keyed by sub-region
|
* individual sub-regions (the RegionMap is region-keyed, v0.19 #16 P1), so each
|
||||||
* id), so each changed id lights up exactly its feature — no expansion to a
|
* changed region key lights up exactly the sub-region that owns it.
|
||||||
* whole country needed.
|
|
||||||
*/
|
*/
|
||||||
function featuresByRegionIds(geo: FeatureCollection, regionIds: Iterable<string>): FeatureCollection {
|
function featuresByRegionKeys(
|
||||||
const set = new Set(regionIds);
|
geo: FeatureCollection,
|
||||||
const features = geo.features.filter((f) => f.id != null && set.has(String(f.id)));
|
keys: Iterable<string>,
|
||||||
|
idKey: Record<string, string>,
|
||||||
|
): FeatureCollection {
|
||||||
|
const set = new Set(keys);
|
||||||
|
const features = geo.features.filter((f) => {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = idKey[id] ?? '';
|
||||||
|
return key !== '' && set.has(key);
|
||||||
|
});
|
||||||
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
|
return { type: 'FeatureCollection', features: JSON.parse(JSON.stringify(features)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Map markers (v0.27, #44) ------------------------------------------------
|
||||||
|
//
|
||||||
|
// The LLM places named point markers (cities, factories, armies, ports,
|
||||||
|
// capitals, fleets, rocket sites, gatherings) anchored to a region key. The
|
||||||
|
// engine positions each at its region's centroid (deterministic small offset)
|
||||||
|
// and recomputes its owner from the region's current owner every turn. The
|
||||||
|
// map renders them as a single top-most symbol layer: a per-type glyph
|
||||||
|
// character in front of the name, per-type color, dark-theme halo.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GeoJSON behind the marker symbol layer: one Point per marker at
|
||||||
|
* `[lon, lat]`, carrying the stable id, type, name, its type's glyph, and the
|
||||||
|
* current owner's name + stable color (empty when the region is unowned).
|
||||||
|
* Pure; the caller passes the current marker list + nations record, so a
|
||||||
|
* renamed/removed owner is picked up on the next paint.
|
||||||
|
*/
|
||||||
|
function buildMarkersFC(markers: MapMarker[], nations: Record<string, Nation>): FeatureCollection {
|
||||||
|
const features = markers.map((m) => {
|
||||||
|
const owner = m.ownerNationId ? nations[m.ownerNationId] : undefined;
|
||||||
|
return {
|
||||||
|
type: 'Feature' as const,
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] as [number, number] },
|
||||||
|
properties: {
|
||||||
|
id: m.id,
|
||||||
|
type: m.type,
|
||||||
|
name: m.name,
|
||||||
|
glyph: MARKER_TYPE_INFO[m.type]?.glyph ?? '',
|
||||||
|
owner: owner?.name ?? '',
|
||||||
|
ownerId: owner?.id ?? '',
|
||||||
|
ownerColor: owner ? nationColor(owner.id) : '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small canvas-path helpers for the marker icons (self-contained shapes — no
|
||||||
|
// unicode/font dependency, so they render identically in any environment).
|
||||||
|
function roundRectPath(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
r: number,
|
||||||
|
): void {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + w - r, y);
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
|
ctx.lineTo(x + w, y + h - r);
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
|
ctx.lineTo(x + r, y + h);
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
|
ctx.lineTo(x, y + r);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function starPath(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
spikes: number,
|
||||||
|
outer: number,
|
||||||
|
inner: number,
|
||||||
|
): void {
|
||||||
|
let rot = (Math.PI / 2) * -1;
|
||||||
|
const step = Math.PI / spikes;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer);
|
||||||
|
for (let i = 0; i < spikes; i++) {
|
||||||
|
rot += step;
|
||||||
|
ctx.lineTo(cx + Math.cos(rot) * inner, cy + Math.sin(rot) * inner);
|
||||||
|
rot += step;
|
||||||
|
ctx.lineTo(cx + Math.cos(rot) * outer, cy + Math.sin(rot) * outer);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (v0.27, #44) Draw one marker-type icon into an offscreen canvas: a distinct
|
||||||
|
* shape per type, filled with the type's color from the shared
|
||||||
|
* `MARKER_TYPE_INFO` contract, with a dark outline for legibility on any
|
||||||
|
* choropleth fill. Added to the map as a named image and referenced by the
|
||||||
|
* symbol layer's `icon-image` — a real symbol layer, per-type color + form,
|
||||||
|
* with zero external font/glyph dependency.
|
||||||
|
*/
|
||||||
|
function drawMarkerIcon(type: MapMarker['type']): ImageData {
|
||||||
|
const S = 44;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = S;
|
||||||
|
c.height = S;
|
||||||
|
const ctx = c.getContext('2d')!;
|
||||||
|
const cx = S / 2;
|
||||||
|
const cy = S / 2;
|
||||||
|
ctx.fillStyle = MARKER_TYPE_INFO[type]?.color ?? '#cbd5e1';
|
||||||
|
ctx.strokeStyle = '#0b1220';
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
switch (type) {
|
||||||
|
case 'city':
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, S * 0.3, 0, Math.PI * 2);
|
||||||
|
break;
|
||||||
|
case 'factory': {
|
||||||
|
const h = S * 0.5;
|
||||||
|
roundRectPath(ctx, cx - h / 2, cy - h / 2, h, h, 5);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'army': {
|
||||||
|
const r = S * 0.34;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy - r);
|
||||||
|
ctx.lineTo(cx + r, cy + r * 0.8);
|
||||||
|
ctx.lineTo(cx - r, cy + r * 0.8);
|
||||||
|
ctx.closePath();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'port': {
|
||||||
|
const r = S * 0.36;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy - r);
|
||||||
|
ctx.lineTo(cx + r, cy);
|
||||||
|
ctx.lineTo(cx, cy + r);
|
||||||
|
ctx.lineTo(cx - r, cy);
|
||||||
|
ctx.closePath();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'capital':
|
||||||
|
starPath(ctx, cx, cy, 5, S * 0.36, S * 0.15);
|
||||||
|
break;
|
||||||
|
case 'fleet':
|
||||||
|
case 'gathering':
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, S * 0.32, 0, Math.PI * 2);
|
||||||
|
break;
|
||||||
|
case 'rocket': {
|
||||||
|
const w = S * 0.14;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy - S * 0.36);
|
||||||
|
ctx.lineTo(cx + S * 0.22, cy - S * 0.04);
|
||||||
|
ctx.lineTo(cx + w, cy - S * 0.04);
|
||||||
|
ctx.lineTo(cx + w, cy + S * 0.34);
|
||||||
|
ctx.lineTo(cx - w, cy + S * 0.34);
|
||||||
|
ctx.lineTo(cx - w, cy - S * 0.04);
|
||||||
|
ctx.lineTo(cx - S * 0.22, cy - S * 0.04);
|
||||||
|
ctx.closePath();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
// fleet and gathering share a circle base; an inner mark tells them apart
|
||||||
|
// (fleet = a dark porthole dot, gathering = a white bullseye dot).
|
||||||
|
if (type === 'fleet' || type === 'gathering') {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, S * 0.13, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = type === 'fleet' ? '#0b1220' : '#ffffff';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
// Hand MapLibre the raw pixels (an untainted canvas, so getImageData is safe)
|
||||||
|
// rather than the canvas element — `addImage` accepts ImageData directly.
|
||||||
|
return ctx.getImageData(0, 0, S, S);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hover tooltip for a marker: its type label + name, then the owner chip. */
|
||||||
|
function markerTooltipHtml(
|
||||||
|
type: string,
|
||||||
|
name: string,
|
||||||
|
owner: string,
|
||||||
|
ownerColor: string,
|
||||||
|
): string {
|
||||||
|
const label = MARKER_TYPE_INFO[type as MapMarker['type']]?.label ?? 'Marker';
|
||||||
|
const ownerLine = owner
|
||||||
|
? `<div class="hm-popup-owner"><span class="hm-chip" style="background:${ownerColor}"></span>${esc(owner)}</div>`
|
||||||
|
: '<div class="hm-popup-sub">No owner</div>';
|
||||||
|
return `<div class="hm-popup"><div class="hm-popup-title">${esc(label)}: ${esc(name)}</div>${ownerLine}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
const esc = (s: string) =>
|
const esc = (s: string) =>
|
||||||
s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
||||||
|
|
||||||
@@ -179,15 +397,22 @@ function statRow(label: string, value?: number): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact hover tooltip: the sub-region's name, its **current owner**
|
* Compact hover tooltip: the sub-region's name, its **current owner**
|
||||||
* (color-chipped), and 1–2 key stats. When the region has no owning nation, a
|
* (color-chipped), and 1–2 key stats. When the region's country has no owning
|
||||||
* minimal "No owner" card is shown instead. The static parent-country code
|
* nation, a minimal card is shown instead; its sub-line says "No man's land"
|
||||||
* (`properties.parent`) is deliberately NOT printed here — it is a geographic
|
* for a region released mid-game (v0.22, #16 P3) and "No owner" for a region
|
||||||
* fact that never updates when ownership is transferred, so only the live
|
* no preset ever assigned. The static parent-country code
|
||||||
* owner chip (resolved from the RegionMap by sub-region id) is shown.
|
* (`properties.parent`) is deliberately NOT printed here — it never updates
|
||||||
|
* when ownership is transferred, so only the live owner chip (resolved from
|
||||||
|
* the RegionMap) is shown.
|
||||||
*/
|
*/
|
||||||
function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string {
|
function hoverHtml(
|
||||||
|
subRegionName: string,
|
||||||
|
region?: RegionOwner,
|
||||||
|
nation?: Nation,
|
||||||
|
noMansLand = false,
|
||||||
|
): string {
|
||||||
if (!region || !nation) {
|
if (!region || !nation) {
|
||||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
|
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
|
||||||
}
|
}
|
||||||
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
|
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
|
||||||
return `<div class="hm-popup">
|
return `<div class="hm-popup">
|
||||||
@@ -205,41 +430,72 @@ interface Props {
|
|||||||
/** The active scenario id, for a sensible initial camera. */
|
/** The active scenario id, for a sensible initial camera. */
|
||||||
presetId: string;
|
presetId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/**
|
||||||
|
* (v0.22, #16 P3) Region keys currently in no man's land (released
|
||||||
|
* mid-game — `GameState.unowned`). Such regions render the distinct visible
|
||||||
|
* grey (vs the faint preset-unassigned look of every other ownerless
|
||||||
|
* region) and their hover tooltip says "No man's land" instead of "No
|
||||||
|
* owner".
|
||||||
|
*/
|
||||||
|
unowned?: string[];
|
||||||
/**
|
/**
|
||||||
* Called with a region's owning-nation id when a sub-region is clicked, so the
|
* Called with a region's owning-nation id when a sub-region is clicked, so the
|
||||||
* dossier switches to that nation. Since v0.19 the owner is the nation that
|
* dossier switches to that nation. The optional `info` carries the clicked
|
||||||
* owns that very sub-region (resolved from the RegionMap by sub-region id).
|
* sub-region's name, for a small "which region" label in the dossier. Not
|
||||||
* 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
|
* called for unowned regions (the dossier keeps its current target). See the
|
||||||
* component doc for the full behavior.
|
* component doc for the full behavior.
|
||||||
*/
|
*/
|
||||||
onFocusNation?: (id: string, info?: { parent?: string; name?: string }) => void;
|
onFocusNation?: (id: string, info?: { name?: string }) => void;
|
||||||
|
/**
|
||||||
|
* (v0.27, #44) The game's named map markers (`GameState.markers`): the LLM
|
||||||
|
* places/removes them over time, the engine positions each at its region's
|
||||||
|
* centroid and keeps its owner in sync with the region's owner. Rendered as
|
||||||
|
* a top-most symbol layer (per-type glyph + name, per-type color).
|
||||||
|
*/
|
||||||
|
markers?: MapMarker[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
|
* World basemap (MapLibre GL) + a **sub-national** choropleth. The map renders
|
||||||
* the finer `regions.geo.json` dataset: each country is subdivided into
|
* 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
|
* ~100–200 km sub-regions, drawn as a finer choropleth with thin internal border
|
||||||
* lines. Since v0.19 ownership and the LLM live at **sub-region** granularity:
|
* lines. Ownership is at **region-key** granularity (v0.19, #16 P1): each
|
||||||
* a nation owns individual sub-region ids, so each feature is filled with the
|
* sub-region has its own region key (from `public/region-keys.json`, looked up
|
||||||
* color of the nation owning that exact sub-region (a country can be split
|
* by the feature's stable id) and the RegionMap is keyed by that key, so a
|
||||||
* between nations; unowned sub-regions render grey). Rendered as a fixed,
|
* single sub-region can change owner independently of its neighbours. Rendered
|
||||||
* full-viewport background layer (z-0) behind the floating HUD windows: a
|
* as a fixed, full-viewport background layer (z-0) behind the floating HUD
|
||||||
* dark-navy basemap with subtle terrain, per-region stable fills (the
|
* windows: a dark-navy basemap with subtle terrain, per-nation stable fills,
|
||||||
* choropleth is the star), thin sub-region borders, a brighter/thicker amber
|
* thin sub-region borders, a brighter/thicker amber border for the player's
|
||||||
* border for the player's regions, a hover tooltip (sub-region name + current
|
* regions, a hover tooltip (sub-region name + current owner + key stats), a
|
||||||
* owner + key stats), a **click that switches the dossier to the sub-region's
|
* **click that switches the dossier to the sub-region's owning nation** (no
|
||||||
* owning nation** (no popup — see #7), a transient flash when a region's owner
|
* popup — see #7), a transient flash when a sub-region's owner changes, and a
|
||||||
* changes (lighting up exactly the changed features), and a per-scenario
|
* per-scenario initial camera.
|
||||||
* initial camera.
|
|
||||||
*
|
*
|
||||||
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
|
* Ownership for a hovered/clicked sub-region is resolved from the authoritative
|
||||||
* RegionMap by the feature's **own sub-region id** (`properties.regionId`, via
|
* RegionMap by its stamped **region key** (`properties.regionKey`, via
|
||||||
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
|
* `mapFeatureOwner` in @shared/protocol) — never from the feature's top-level
|
||||||
* `id` (MapLibre repacks it into a numeric index at interaction time, #6).
|
* `id` (MapLibre repacks it into a numeric index at interaction time, #6) and
|
||||||
* Unowned sub-regions (no RegionMap entry for the id) simply do not change the
|
* never from the raw sub-region id (not a RegionMap key). Unowned sub-regions
|
||||||
* dossier.
|
* (no RegionMap entry for their key) simply do not change the dossier.
|
||||||
|
*
|
||||||
|
* (v0.22, #16 P3) An unowned region renders two distinct ways: a key in the
|
||||||
|
* `unowned` prop (released to no man's land mid-game — `GameState.unowned`)
|
||||||
|
* gets the distinct visible grey with a "No man's land" tooltip sub-line; every
|
||||||
|
* other ownerless region (a preset never assigned it) keeps the faint look and
|
||||||
|
* a plain "No owner" sub-line. The colors/labels come from the shared
|
||||||
|
* `regionFillStyle` / `unownedTooltipLabel` contract in @shared/protocol.
|
||||||
|
*
|
||||||
|
* (v0.27, #44) LLM-placed **map markers** (`markers` prop = `GameState.markers`)
|
||||||
|
* render on a top-most symbol layer: each is its type's canvas-generated icon
|
||||||
|
* (a distinct shape, per-type color from the shared `MARKER_TYPE_INFO`
|
||||||
|
* contract, dark-outlined) at the engine-derived position (region centroid +
|
||||||
|
* small deterministic offset). The icons are drawn into named map images
|
||||||
|
* (`marker-<type>`), so there is no external font/glyph dependency and markers
|
||||||
|
* render offline and deterministically. Hovering a marker shows a tooltip
|
||||||
|
* (type label + name + owner chip); clicking one switches the dossier to the
|
||||||
|
* owning nation (no-op when the marker's region is unowned). The region
|
||||||
|
* hover/click handlers detect a marker under the cursor (MapLibre layer events
|
||||||
|
* have no propagation) and act on the marker's data.
|
||||||
*
|
*
|
||||||
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
||||||
* — no floating control and no external API.
|
* — no floating control and no external API.
|
||||||
@@ -250,22 +506,31 @@ export default function WorldMap({
|
|||||||
nations,
|
nations,
|
||||||
presetId,
|
presetId,
|
||||||
className,
|
className,
|
||||||
|
unowned,
|
||||||
onFocusNation,
|
onFocusNation,
|
||||||
|
markers,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
const baseGeoRef = useRef<FeatureCollection | null>(null);
|
const baseGeoRef = useRef<FeatureCollection | null>(null);
|
||||||
|
// The region-key manifest (feature id -> region key), loaded once; used to
|
||||||
|
// stamp each feature's region key and to expand the flash to its sub-region.
|
||||||
|
const idKeyRef = useRef<Record<string, string> | null>(null);
|
||||||
// Latest props, so the mount effect's (re)establish closure always sees them.
|
// Latest props, so the mount effect's (re)establish closure always sees them.
|
||||||
const regionsRef = useRef(regions);
|
const regionsRef = useRef(regions);
|
||||||
const nationsRef = useRef(nations);
|
const nationsRef = useRef(nations);
|
||||||
const playerRef = useRef(playerNationId);
|
const playerRef = useRef(playerNationId);
|
||||||
const presetRef = useRef(presetId);
|
const presetRef = useRef(presetId);
|
||||||
|
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
|
||||||
const onFocusNationRef = useRef(onFocusNation);
|
const onFocusNationRef = useRef(onFocusNation);
|
||||||
|
const markersRef = useRef<MapMarker[]>(markers ?? []);
|
||||||
regionsRef.current = regions;
|
regionsRef.current = regions;
|
||||||
nationsRef.current = nations;
|
nationsRef.current = nations;
|
||||||
playerRef.current = playerNationId;
|
playerRef.current = playerNationId;
|
||||||
presetRef.current = presetId;
|
presetRef.current = presetId;
|
||||||
|
unownedRef.current = new Set(unowned ?? []);
|
||||||
onFocusNationRef.current = onFocusNation;
|
onFocusNationRef.current = onFocusNation;
|
||||||
|
markersRef.current = markers ?? [];
|
||||||
|
|
||||||
// Diffing state for the transient territory-change flash (owned by this component
|
// Diffing state for the transient territory-change flash (owned by this component
|
||||||
// so a resync with identical data never re-flashes).
|
// so a resync with identical data never re-flashes).
|
||||||
@@ -310,17 +575,28 @@ export default function WorldMap({
|
|||||||
return g;
|
return g;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The region-key manifest (feature id -> region key), fetched once and
|
||||||
|
// cached in a ref so both the paint path and the flash can resolve keys.
|
||||||
|
const loadIdKey = (): Promise<Record<string, string>> =>
|
||||||
|
idKeyRef.current
|
||||||
|
? Promise.resolve(idKeyRef.current)
|
||||||
|
: idToKey().then((k) => {
|
||||||
|
idKeyRef.current = k;
|
||||||
|
return k;
|
||||||
|
});
|
||||||
|
|
||||||
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
|
const emptyFC = (): FeatureCollection => ({ type: 'FeatureCollection', features: [] });
|
||||||
|
|
||||||
// Transient flash: pulse a bright outline over the changed regions, then fade
|
// 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`
|
// to zero over a few seconds and self-clean. Idempotent per call. `codes` are
|
||||||
// are sub-region ids (the RegionMap keys that changed owner since the last
|
// region keys (a turn can change individual sub-regions, v0.19 #16 P1); each
|
||||||
// update); each lights up exactly its feature.
|
// lights up exactly the sub-region that owns that key.
|
||||||
function animateFlash(codes: string[]) {
|
function animateFlash(codes: string[]) {
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
|
const idKey = idKeyRef.current;
|
||||||
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(FLASH_SOURCE) as GeoSource | undefined;
|
||||||
if (!geo || !src || cancelled) return;
|
if (!geo || !idKey || !src || cancelled) return;
|
||||||
src.setData(featuresByRegionIds(geo, codes));
|
src.setData(featuresByRegionKeys(geo, codes, idKey));
|
||||||
if (!map.getLayer(FLASH_LINE)) {
|
if (!map.getLayer(FLASH_LINE)) {
|
||||||
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
|
// Defensive re-add (normally created by ensureLayers). Keep it beneath the
|
||||||
// hover highlight when that layer exists; otherwise just append.
|
// hover highlight when that layer exists; otherwise just append.
|
||||||
@@ -355,13 +631,46 @@ export default function WorldMap({
|
|||||||
|
|
||||||
// The sub-region a hovered/clicked feature belongs to is identified by its
|
// The sub-region a hovered/clicked feature belongs to is identified by its
|
||||||
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
|
// stable sub-region id in `properties.regionId` (stamped by `applyRegions`),
|
||||||
// and its **owner** is resolved from the authoritative RegionMap via that
|
// and its **owner** is resolved from the authoritative RegionMap via the
|
||||||
// same `properties.regionId` through `mapFeatureOwner` — since v0.19 the
|
// feature's stamped `properties.regionKey` through
|
||||||
// RegionMap is keyed by the sub-region's own id. Never the repacked
|
// `mapFeatureOwner` — never from the repacked top-level `id` (a numeric
|
||||||
// top-level `id` (a numeric index, #6).
|
// index, #6) and never from the raw sub-region id (not a RegionMap key).
|
||||||
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
|
// (v0.27, #44) A marker rendered on top of this region wins the hover.
|
||||||
|
// The region polygon is always beneath a marker, so this FILL_LAYER
|
||||||
|
// delegate fires in both cases; if a marker icon is under the cursor,
|
||||||
|
// show the marker's tooltip (name + type + owner) instead of the
|
||||||
|
// region's. `e.point` is the cursor's pixel position, so querying the
|
||||||
|
// marker layer there tests the icon's (small) hit area, not the whole
|
||||||
|
// region.
|
||||||
|
const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] });
|
||||||
|
if (markerHits.length > 0) {
|
||||||
|
const mp = (markerHits[0].properties ?? {}) as {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
owner?: string;
|
||||||
|
ownerColor?: string;
|
||||||
|
};
|
||||||
|
hoverPopup
|
||||||
|
.setLngLat(e.lngLat)
|
||||||
|
.setHTML(
|
||||||
|
markerTooltipHtml(
|
||||||
|
typeof mp.type === 'string' ? mp.type : '',
|
||||||
|
typeof mp.name === 'string' ? mp.name : '',
|
||||||
|
typeof mp.owner === 'string' ? mp.owner : '',
|
||||||
|
typeof mp.ownerColor === 'string' ? mp.ownerColor : '',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addTo(map);
|
||||||
|
map.getCanvas().style.cursor = 'pointer';
|
||||||
|
// A marker has no region outline to highlight; clear the previous one
|
||||||
|
// (and reset lastHoverId so moving back onto a region re-triggers it).
|
||||||
|
lastHoverId = null;
|
||||||
|
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(emptyFC());
|
||||||
|
return;
|
||||||
|
}
|
||||||
const p = f.properties as { name?: string; regionId?: string };
|
const p = f.properties as { name?: string; regionId?: string };
|
||||||
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
|
const subId = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||||
if (subId && subId !== lastHoverId) {
|
if (subId && subId !== lastHoverId) {
|
||||||
@@ -377,9 +686,14 @@ export default function WorldMap({
|
|||||||
}
|
}
|
||||||
const region = mapFeatureOwner(regionsRef.current, f);
|
const region = mapFeatureOwner(regionsRef.current, f);
|
||||||
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
||||||
|
// (v0.22, #16 P3) whether this region is no man's land (released
|
||||||
|
// mid-game), so the ownerless tooltip can say so.
|
||||||
|
const pKey = f.properties?.regionKey;
|
||||||
|
const noMansLand =
|
||||||
|
!region && typeof pKey === 'string' && pKey !== '' && unownedRef.current.has(pKey);
|
||||||
hoverPopup
|
hoverPopup
|
||||||
.setLngLat(e.lngLat)
|
.setLngLat(e.lngLat)
|
||||||
.setHTML(hoverHtml(p?.name ?? subId, region, nation))
|
.setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand))
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
map.getCanvas().style.cursor = 'pointer';
|
map.getCanvas().style.cursor = 'pointer';
|
||||||
};
|
};
|
||||||
@@ -392,22 +706,32 @@ export default function WorldMap({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
||||||
// owning nation. Since v0.19 the owner is the nation that owns **this**
|
// owning nation, resolved from the feature's stamped `properties.regionKey`
|
||||||
// sub-region, resolved from `properties.regionId` via the RegionMap (never
|
// via the RegionMap (never the repacked top-level `f.id`, #6, and never the
|
||||||
// the repacked top-level `f.id`, #6). A sub-region with no owner leaves the
|
// raw sub-region id). A sub-region whose key has no owner leaves the current
|
||||||
// current dossier target unchanged — never a broken dossier.
|
// dossier target unchanged — never a broken dossier.
|
||||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||||
const f = e.features?.[0];
|
const f = e.features?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
|
// (v0.27, #44) A marker under the cursor: focus its owning nation
|
||||||
|
// (identical to its region's owner — the engine keeps a marker's owner in
|
||||||
|
// sync with the region) and carry the marker's name into the dossier.
|
||||||
|
const markerHits = map.queryRenderedFeatures(e.point, { layers: [MARKER_SYMBOL] });
|
||||||
|
if (markerHits.length > 0) {
|
||||||
|
const mp = (markerHits[0].properties ?? {}) as { ownerId?: string; name?: string };
|
||||||
|
const owner = typeof mp.ownerId === 'string' ? mp.ownerId : '';
|
||||||
|
if (owner) {
|
||||||
|
const info = typeof mp.name === 'string' ? { name: mp.name } : undefined;
|
||||||
|
onFocusNationRef.current?.(owner, info);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const region = mapFeatureOwner(regionsRef.current, f);
|
const region = mapFeatureOwner(regionsRef.current, f);
|
||||||
if (!region?.ownerId) return;
|
if (!region?.ownerId) return;
|
||||||
// Carry the clicked sub-region's identity (own id/name + parent ISO_A3) so
|
// Carry the clicked sub-region's name so the dossier can show which region
|
||||||
// the dossier can show which region was selected.
|
// was selected.
|
||||||
const p = f.properties as { regionId?: string; parent?: string; name?: string };
|
const p = f.properties as { name?: string };
|
||||||
const info = {
|
const info = { name: typeof p?.name === 'string' ? p.name : undefined };
|
||||||
parent: typeof p?.parent === 'string' ? p.parent : undefined,
|
|
||||||
name: typeof p?.name === 'string' ? p.name : undefined,
|
|
||||||
};
|
|
||||||
onFocusNationRef.current?.(region.ownerId, info);
|
onFocusNationRef.current?.(region.ownerId, info);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -433,36 +757,66 @@ export default function WorldMap({
|
|||||||
if (!map.getSource(FLASH_SOURCE)) {
|
if (!map.getSource(FLASH_SOURCE)) {
|
||||||
map.addSource(FLASH_SOURCE, { type: 'geojson', data: emptyFC() });
|
map.addSource(FLASH_SOURCE, { type: 'geojson', data: emptyFC() });
|
||||||
}
|
}
|
||||||
|
if (!map.getSource(MARKERS_SOURCE)) {
|
||||||
|
map.addSource(MARKERS_SOURCE, { type: 'geojson', data: emptyFC() });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stamp ownership + colors onto the regions source (no-op until geo loads).
|
// Stamp ownership + colors onto the regions source (no-op until both the
|
||||||
|
// base geo and the region-key manifest are loaded).
|
||||||
const paintRegions = () => {
|
const paintRegions = () => {
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
|
const idKey = idKeyRef.current;
|
||||||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||||||
if (!geo || !src) return;
|
if (!geo || !idKey || !src) return;
|
||||||
src.setData(applyRegions(geo, regionsRef.current, playerRef.current));
|
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stamp the current markers onto the marker source (v0.27, #44).
|
||||||
|
const paintMarkers = () => {
|
||||||
|
const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined;
|
||||||
|
if (!src) return;
|
||||||
|
src.setData(buildMarkersFC(markersRef.current, nationsRef.current));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register the eight canvas-generated marker-type icon images (v0.27, #44).
|
||||||
|
// Images are cleared on a style (re)load, so this is (re)run from
|
||||||
|
// `ensureLayers` (which fires on every `styledata`). Self-contained — no
|
||||||
|
// external font/glyph endpoint — so markers render offline and
|
||||||
|
// deterministically.
|
||||||
|
const ensureMarkerImages = async () => {
|
||||||
|
for (const type of MARKER_TYPES) {
|
||||||
|
const id = `marker-${type}`;
|
||||||
|
if (map.getImage(id)) continue;
|
||||||
|
await map.addImage(id, drawMarkerIcon(type));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureLayers = async () => {
|
const ensureLayers = async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
ensureSources();
|
ensureSources();
|
||||||
if (baseGeoRef.current) {
|
if (!baseGeoRef.current) {
|
||||||
paintRegions();
|
|
||||||
} else {
|
|
||||||
try {
|
try {
|
||||||
await loadGeo();
|
await loadGeo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('failed to load regions geojson', err);
|
console.error('failed to load regions geojson', err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!idKeyRef.current) {
|
||||||
|
try {
|
||||||
|
await loadIdKey();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to load region keys', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
paintRegions();
|
paintRegions();
|
||||||
}
|
paintMarkers();
|
||||||
if (!map.getLayer(FILL_LAYER)) {
|
if (!map.getLayer(FILL_LAYER)) {
|
||||||
// The choropleth is the star: a high opacity so the vivid per-nation
|
// The choropleth is the star: a high opacity so the vivid per-nation
|
||||||
// colors read clearly even on the dark background / over the terrain.
|
// colors read clearly even on the dark background / over the terrain.
|
||||||
// Since v0.19 each sub-region carries the color of the nation owning
|
// Each sub-region carries its own owning nation's color (v0.19 #16 P1),
|
||||||
// that exact sub-region, so a country taken at region level shows the
|
// so the fill traces region-key ownership, not country borders.
|
||||||
// split directly in the fill.
|
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: FILL_LAYER,
|
id: FILL_LAYER,
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
@@ -484,8 +838,9 @@ export default function WorldMap({
|
|||||||
}
|
}
|
||||||
// NOTE: layers are added in bottom→top order (each appends on top). The
|
// NOTE: layers are added in bottom→top order (each appends on top). The
|
||||||
// stack is: fill, base borders, player border, territory-change flash,
|
// stack is: fill, base borders, player border, territory-change flash,
|
||||||
// hover brighten, hover outline. Do not use a `beforeId` here — it throws
|
// hover brighten, hover outline, and (top-most) the LLM-placed map
|
||||||
// if that layer does not exist yet on a fresh style load.
|
// markers' symbol layer (v0.27, #44). Do not use a `beforeId` here — it
|
||||||
|
// throws if that layer does not exist yet on a fresh style load.
|
||||||
if (!map.getLayer(PLAYER_LINE)) {
|
if (!map.getLayer(PLAYER_LINE)) {
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: PLAYER_LINE,
|
id: PLAYER_LINE,
|
||||||
@@ -519,6 +874,36 @@ export default function WorldMap({
|
|||||||
paint: { 'line-color': '#ffffff', 'line-width': 1.6, 'line-opacity': 0.9 },
|
paint: { 'line-color': '#ffffff', 'line-width': 1.6, 'line-opacity': 0.9 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// (v0.27, #44) The LLM-placed map markers: a top-most symbol layer.
|
||||||
|
// Each marker renders its type's canvas-generated icon (a distinct shape,
|
||||||
|
// per-type color from the shared MARKER_TYPE_INFO contract) with a dark
|
||||||
|
// outline for legibility on any choropleth fill. The icon images are
|
||||||
|
// self-contained (no external font/glyph endpoint), so markers render
|
||||||
|
// offline and deterministically. `icon-image` is `marker-<type>`, the
|
||||||
|
// named canvas images registered by `ensureMarkerImages` above.
|
||||||
|
await ensureMarkerImages();
|
||||||
|
if (!map.getLayer(MARKER_SYMBOL)) {
|
||||||
|
map.addLayer({
|
||||||
|
id: MARKER_SYMBOL,
|
||||||
|
type: 'symbol',
|
||||||
|
source: MARKERS_SOURCE,
|
||||||
|
layout: {
|
||||||
|
'icon-image': ['concat', 'marker-', ['get', 'type']],
|
||||||
|
'icon-size': 0.8,
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
'icon-ignore-placement': true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Marker interactions: the markers sit above the choropleth, but they
|
||||||
|
// deliberately share the region (FILL_LAYER) handlers registered below.
|
||||||
|
// MapLibre layer-scoped events are independent per-layer delegates (no
|
||||||
|
// propagation, no stopPropagation), so a marker-on-region would fire
|
||||||
|
// both a marker and a region handler. Instead the region handlers detect
|
||||||
|
// a marker under the cursor (see onHover/onClick) and act on the
|
||||||
|
// marker's data; clicking a marker focuses its owning nation via the
|
||||||
|
// same path as clicking its region (the marker's owner == its region's
|
||||||
|
// owner).
|
||||||
map.off('mousemove', FILL_LAYER, onHover);
|
map.off('mousemove', FILL_LAYER, onHover);
|
||||||
map.off('mouseout', FILL_LAYER, onHoverOut);
|
map.off('mouseout', FILL_LAYER, onHoverOut);
|
||||||
map.off('click', FILL_LAYER, onClick);
|
map.off('click', FILL_LAYER, onClick);
|
||||||
@@ -542,32 +927,52 @@ export default function WorldMap({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Re-color the choropleth whenever ownership changes, and flash any sub-region
|
// Re-color the choropleth whenever ownership changes, and flash each
|
||||||
// whose owner changed since the last update (a transient, self-cleaning pulse).
|
// sub-region whose owner changed since the last update (a transient,
|
||||||
// The diff is over the sub-region-id-keyed RegionMap; `animateFlash` lights up
|
// self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19
|
||||||
// exactly the features whose own id changed owner.
|
// #16 P1); `animateFlash` lights up exactly the sub-region for each changed
|
||||||
|
// key. Re-runs when the no-man's-land set changes too (v0.22 #16 P3) so a
|
||||||
|
// release/re-claim repaints the grey even if the RegionMap diff alone
|
||||||
|
// wouldn't catch it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
const geo = baseGeoRef.current;
|
const geo = baseGeoRef.current;
|
||||||
|
const idKey = idKeyRef.current;
|
||||||
const cur = regions;
|
const cur = regions;
|
||||||
const prev = prevRegionsRef.current;
|
const prev = prevRegionsRef.current;
|
||||||
prevRegionsRef.current = cur;
|
prevRegionsRef.current = cur;
|
||||||
const wasPrimed = primedRef.current;
|
const wasPrimed = primedRef.current;
|
||||||
if (Object.keys(cur).length > 0) primedRef.current = true;
|
if (Object.keys(cur).length > 0) primedRef.current = true;
|
||||||
|
const unownedSet = new Set(unowned ?? []);
|
||||||
|
|
||||||
if (map && geo) {
|
if (map && geo && idKey) {
|
||||||
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
const src = map.getSource(REGIONS_SOURCE) as GeoSource | undefined;
|
||||||
if (src) void src.setData(applyRegions(geo, cur, playerNationId));
|
if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasPrimed && prev) {
|
if (wasPrimed && prev && idKey) {
|
||||||
const changed = new Set<string>();
|
const changed = new Set<string>();
|
||||||
for (const regionId of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
|
for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
|
||||||
if ((prev[regionId]?.ownerId ?? '') !== (cur[regionId]?.ownerId ?? '')) changed.add(regionId);
|
if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key);
|
||||||
}
|
}
|
||||||
|
// A release (owned → unowned) and a re-claim (unowned → owned) both show
|
||||||
|
// up as ownerId changes above, so they flash like any other transfer.
|
||||||
if (changed.size > 0) flashApiRef.current?.([...changed]);
|
if (changed.size > 0) flashApiRef.current?.([...changed]);
|
||||||
}
|
}
|
||||||
}, [regions, playerNationId]);
|
}, [regions, playerNationId, unowned]);
|
||||||
|
|
||||||
|
// (v0.27, #44) Repaint the marker symbol layer whenever the marker list
|
||||||
|
// changes (a turn placed/removed markers) or the nations record changes
|
||||||
|
// (a confirmed rename/merge changes an owner's display name). No-op until
|
||||||
|
// the map + source exist (the mount effect's ensureLayers applies the
|
||||||
|
// current data once the layer structure is in place).
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
const src = map.getSource(MARKERS_SOURCE) as GeoSource | undefined;
|
||||||
|
if (!src) return;
|
||||||
|
src.setData(buildMarkersFC(markers ?? [], nations));
|
||||||
|
}, [markers, nations]);
|
||||||
|
|
||||||
// Full-viewport, edge-to-edge background layer. `position: fixed` is set
|
// Full-viewport, edge-to-edge background layer. `position: fixed` is set
|
||||||
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to
|
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import type {
|
|||||||
AddActionResponse,
|
AddActionResponse,
|
||||||
AddDiplomacyResponse,
|
AddDiplomacyResponse,
|
||||||
AdvisorResponse,
|
AdvisorResponse,
|
||||||
|
ConfirmNationOpResponse,
|
||||||
CreateGameResponse,
|
CreateGameResponse,
|
||||||
DeleteGameResponse,
|
DeleteGameResponse,
|
||||||
GameSummary,
|
GameSummary,
|
||||||
|
RejectNationOpResponse,
|
||||||
RemoveQueueItemResponse,
|
RemoveQueueItemResponse,
|
||||||
TurnResponse,
|
TurnResponse,
|
||||||
} from '@shared/protocol';
|
} from '@shared/protocol';
|
||||||
@@ -60,6 +62,24 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ index }),
|
body: JSON.stringify({ index }),
|
||||||
}),
|
}),
|
||||||
|
// (v0.23, #16 P4a; v0.24, #16 P4b) Confirm a pending nation-op proposal by
|
||||||
|
// its index in `game.pendingNationOps`: a rename takes effect (`edits.name`
|
||||||
|
// overrides the proposed name), a split partitions the nation's territory
|
||||||
|
// (`edits.playerPart` picks which part the player leads, default 0), and a
|
||||||
|
// merge folds its inputs into the survivor (`edits.name` overrides the
|
||||||
|
// merged name). Fast, non-LLM — no turn advance. Returns the updated game.
|
||||||
|
confirmNationOp: (id: string, index: number, edits?: { name?: string; playerPart?: number }) =>
|
||||||
|
req<ConfirmNationOpResponse>(`/api/games/${id}/nation-op/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ index, ...(edits ? { edits } : {}) }),
|
||||||
|
}),
|
||||||
|
// (v0.23, #16 P4a) Reject (drop) a pending nation-op proposal by index,
|
||||||
|
// without applying it. Fast, non-LLM — no turn advance. Returns the updated game.
|
||||||
|
rejectNationOp: (id: string, index: number) =>
|
||||||
|
req<RejectNationOpResponse>(`/api/games/${id}/nation-op/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ index }),
|
||||||
|
}),
|
||||||
// Commit the whole pending round (or an empty one) in a single LLM turn.
|
// Commit the whole pending round (or an empty one) in a single LLM turn.
|
||||||
jump: (id: string) =>
|
jump: (id: string) =>
|
||||||
req<TurnResponse>(`/api/games/${id}/jump`, {
|
req<TurnResponse>(`/api/games/${id}/jump`, {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Client-side region-key data access. Fetches the two static JSON files served
|
||||||
|
// from public/ and builds the lookups the region-level UI needs:
|
||||||
|
// - idToKey(): feature id -> region key (to stamp each map feature + resolve owner)
|
||||||
|
// - keyToName(): region key -> human name (to show region names in the dossier)
|
||||||
|
// Each is fetched once and cached (Promise singleton) so WorldMap and NationPanel
|
||||||
|
// share a single fetch. Same-origin relative URLs (proxy/Traefik safe) — never a
|
||||||
|
// hardcoded origin.
|
||||||
|
type GeoLike = {
|
||||||
|
features?: Array<{ id?: string | number; properties?: { name?: string } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let idKeyPromise: Promise<Record<string, string>> | null = null;
|
||||||
|
let keyNamePromise: Promise<Record<string, string>> | null = null;
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
|
||||||
|
return (await r.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** feature id (from regions.geo.json) -> region key (from region-keys.json). */
|
||||||
|
export function idToKey(): Promise<Record<string, string>> {
|
||||||
|
idKeyPromise ??= fetchJson<Record<string, string>>('/region-keys.json');
|
||||||
|
return idKeyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* region key -> human region name. Built by joining region-keys.json (id -> key)
|
||||||
|
* with regions.geo.json (id -> properties.name). A key with no known name is
|
||||||
|
* simply absent (callers fall back to the raw key).
|
||||||
|
*/
|
||||||
|
export function keyToName(): Promise<Record<string, string>> {
|
||||||
|
keyNamePromise ??= Promise.all([idToKey(), fetchJson<GeoLike>('/regions.geo.json')]).then(
|
||||||
|
([keys, geo]) => {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const f of geo.features ?? []) {
|
||||||
|
const id = f.id == null ? '' : String(f.id);
|
||||||
|
const key = keys[id];
|
||||||
|
const name = f.properties?.name;
|
||||||
|
if (key && typeof name === 'string') out[key] = name;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return keyNamePromise;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user