Compare commits

..
Author SHA1 Message Date
kamilkrzywda cb426273a9 Merge pull request 'Region transfers along real borders + getNeighbours LLM tool (v0.21.0) — part 2 of #16' (#36) from feat/16-p2-region-transfers into master
CI / build + unit + e2e (push) Successful in 1m31s
2026-10-14 12:51:30 +02:00
kamilkrzywda 0b999f1448 Bump to v0.21.0 (region transfers + getNeighbours tool, #16 P2)
CI / build + unit + e2e (pull_request) Successful in 1m34s
2026-10-14 10:48:28 +00:00
kamilkrzywda 5abbebc58f LLM getNeighbours tool + contiguous region transfers (Stage 2 of #16 P2)
- llm.ts: LlmClient.chatWithTools — bounded OpenAI function-calling loop
  (default 6 tool rounds, then one forced no-tools request); shares the
  retry/backoff/reasoning-effort machinery with chat(); tools are only
  added to the request body when non-empty, so plain chat() bodies and
  their tests are unchanged; a tool-call response is a success (never a
  truncation); malformed tool arguments become an error tool-message.
- engine.ts: GET_NEIGHBOURS_TOOL + makeNeighboursExecutor (degrades
  unknown tools/args/keys without throwing); playerFrontier (sorted,
  deduped, other-nation-owned only); buildSystemPrompt gains a tool
  variant (contiguity rules, frontier hint) used only when the tool is
  actually sent; buildUserPrompt emits a frontier field whenever a
  resolver is given; runTurn takes getNeighbours and uses
  chatWithTools when both the resolver and the client's capability are
  present, falling back to plain chat() otherwise.
- applyTurnResult: snapshot-first sole-ownership algorithm — a region
  key transfers only with exactly one claimant and exactly one previous
  owner (different from the gainer); double claims and pre-existing
  double ownership are left untouched; lone losses are no-ops; every
  key has 0 or 2+ -> 1 owner post-turn, never orphaned or duplicated.
- stub.ts: deterministic one-region contiguous conquest per turn from
  the prompt's frontier field (bypasses the tool loop; empty frontier
  -> no transfer, all existing assertions stay stable).
- regions.ts: optional region-adjacency.json load; hasAdjacency() and
  neighboursOf(keys) (union, deduped, queried excluded, sorted).
- index.ts: jump route passes the adjacency resolver when loaded.
- tests: chatWithTools protocol (echo, executor args, bounded rounds,
  malformed args, retry), tool-path runTurn wiring + fallback,
  playerFrontier, executor, applyTurnResult invariants, neighboursOf/
  hasAdjacency (105 unit tests, 14 e2e — all green).
2026-10-14 10:37:25 +00:00
kamilkrzywda f53f9072f4 Build region-adjacency manifest (Stage 1 of #16 P2)
Generate public/region-adjacency.json: regionKey -> neighbourKeys, built
from regions.geo.json (bbox proximity reject + @turf/boolean-intersects
arbitration; symmetric, deduped, no self-refs). Deterministic + idempotent.

This is the data backing the LLM's getNeighbours tool (Stage 2) which lets
it move contiguous frontier regions instead of whole countries.
2026-10-14 10:05:06 +00:00
kamilkrzywda 6f14a4e4c0 Merge pull request 'Fix: region loader finds the manifest in prod (dist/) — completes P1 region ownership (v0.20.0)' (#35) from fix/16-p1-region-keys-prod into master
CI / build + unit + e2e (push) Successful in 1m36s
Merge v0.20.0: region loader prod-path fix (completes #16 P1)
2026-10-14 11:35:22 +02:00
kamilkrzywda 6949739956 Bump to v0.20.0 (region loader prod-path fix)
CI / build + unit + e2e (pull_request) Successful in 1m31s
2026-10-14 09:32:29 +00:00
kamilkrzywda cf7d3cb122 Fix region loader to find the manifest in prod (dist/) — dev-vs-prod path (v0.20.0, completes #16 P1)
v0.19.0's region-level ownership did not activate in the production image:
server/regions.ts loadRegions only looked in public/, but the Docker image keeps
only dist/ (Vite copies public/* -> dist/ at build), so the loader found nothing
and expandCountriesToKeys degraded to a no-op — fresh games kept raw ISO_A3 codes
instead of region keys (the client's own /region-keys.json fetch still worked,
since the client is served from dist/).

loadRegions now probes public/ (dev) then dist/ (prod, == clientDir) and uses the
first directory holding both files. A new test covers the prod-only (dist/), dev
(public/), and empty-root (no data) layouts.
2026-10-14 09:32:29 +00:00
kamilkrzywda 99ee93f6da Merge pull request 'Region-level ownership foundation (Part 1 of #16) (v0.19.0)' (#34) from feat/16-p1-region-ownership into master
CI / build + unit + e2e (push) Successful in 1m35s
Merge v0.19.0: region-level ownership (Part 1 of #16)
2026-10-14 11:18:12 +02:00
kamilkrzywda b9ac79c57f Bump to v0.19.0: region-level ownership (Part 1 of #16)
CI / build + unit + e2e (pull_request) Successful in 1m36s
Version 0.19.0 + changelog. Breaking: pre-v0.19 saved games are dropped
(territory now holds region keys, not ISO_A3 codes) — deploy wipes the
history-data volume.
2026-10-14 09:15:33 +00:00
kamilkrzywda 1c64aa1da4 Tests: region-key contract (map rewrite + regions loader + engine) (v0.19 P1 of #16)
- map.test.ts: rewrite the fixture for region keys; the #6 regression guard now
  proves the owner resolves via properties.regionKey while the repacked numeric
  feature.id and the sub-region regionId do not.
- regions.test.ts (new): the loader sees 1,796 valid keys; FRA expands to its
  42 sub-region keys (all valid, all labelled '… (FRA)'); allTerritoryKeys
  unions correctly; and with no data it degrades to the raw input.
- engine.test.ts: buildUserPrompt includes a regions table only when provided;
  applyTurnResult with a validKeys set applies real keys and ignores bogus ones,
  and without one applies all (the legacy/unit path).
2026-10-14 09:14:24 +00:00
kamilkrzywda 29927bae34 Flip the client to region-key resolution + region names in the dossier (v0.19 P1 of #16)
- src/lib/regions.ts (new): cached, turf-free same-origin fetches of
  /region-keys.json (feature id -> key) and /regions.geo.json (id -> name) so
  WorldMap and NationPanel share one fetch each.
- WorldMap.tsx: stamps each feature's properties.regionKey (from the manifest,
  by its stable id) and resolves owner / flash / hover / click from that key via
  the shared mapFeatureOwner; the per-region flash lights the exact sub-region
  whose owner changed; click carries only the sub-region name.
- NationPanel.tsx: territory chips show the region's human name (falling back
  to the raw key) with the key in the title / data-attr.
- App.tsx: the selected-region dossier label drops the now-unused parent code.
2026-10-14 09:14:17 +00:00
kamilkrzywda 325bdc5255 Flip ownership to region keys: shared contract + server (v0.19 P1 of #16)
The single source of truth now keys ownership by region key (one per owned
sub-region) instead of a parent-country ISO_A3:

- shared/types.ts, shared/protocol.ts: TerritoryChange / RegionMap / regionOwner
  doc flips; mapFeatureOwner now reads the feature's stamped properties.regionKey
  (never the repacked numeric feature.id, nor the sub-region id — the #6 guard).
- server/regions.ts (new, turf-free): loads region-keys.json + regions.geo.json
  once at boot; expands a nation's ISO_A3 list to its sub-regions' keys, builds
  the LLM 'regions' reference table (key -> "name (parent)"), and exposes the
  set of valid keys. Degrades gracefully when the files are missing.
- server/engine.ts: buildSystemPrompt instructs the model to use region keys;
  buildUserPrompt gains an optional regionReference table; applyTurnResult gains
  an optional validKeys set so hallucinated keys are ignored.
- server/index.ts: loads region data at boot; expands each nation's country list
  to region keys at game creation; the jump route passes the reference + valid
  keys through runTurn.

The stub is unchanged (it emits an empty, region-key-conformant territoryChanges).
2026-10-14 09:14:10 +00:00
kamilkrzywda 2cfa2f78f9 Add region-keys.test.ts: 1,796 unique <slug>-<4char> keys, geo-id join (v0.19 P1 of #16) 2026-10-14 08:32:36 +00:00
kamilkrzywda 2b84c34f41 Add region-key manifest (build:region-keys) + public/region-keys.json (v0.19 P1 of #16)
Deterministic <name-slug>-<4char> key per sub-region: name via NFKD strip +
fold table (ə ø ð đ œ ł) + a-z0-9 collapse; 4-char from FNV-1a of the stable
feature id, salted on within-slug collision so all 1,796 keys are unique.
Builtins only (no @turf). Idempotent (byte-identical on re-run).
2026-10-14 08:32:36 +00:00
26 changed files with 16457 additions and 712 deletions
+74 -33
View File
@@ -28,12 +28,19 @@ 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,796 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.
- 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 +52,29 @@ 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; 105 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,796 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
│ ├── region-keys.json # feature id -> region key, 1,796 entries (the ownership/LLM address space, #16 P1)
│ └── region-adjacency.json # region key -> bordering region keys, 1,796 entries (the getNeighbours data, #16 P2)
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
│ │ # fill-gaps → build-region-keys → build-region-adjacency
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both 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 + AddAction/AddDiplomacy/RemoveQueueItem/Jump 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|games|map|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / removeRoundItem / advisor │ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor
│ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config │ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER │ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
│ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color) │ ├── 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)
@@ -91,10 +105,11 @@ 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/`; 105 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 build:region-keys` / `build:region-adjacency` | (re)generate the `public/region-keys.json` / `region-adjacency.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 +158,45 @@ 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.
- **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. 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 +239,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 +284,10 @@ 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 — 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).
+23 -9
View File
@@ -2,15 +2,29 @@
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.21.0 — 2026-10-14 10:37
- **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.** - **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:
- `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). - **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.
- **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).** - **`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.
- **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`). - **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.
- **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). - **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.
- **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. - **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.
- **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. - **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`).
- Part of #16 - **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.
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "history", "name": "history",
"version": "0.19.0", "version": "0.21.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,8 @@
"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",
"build:region-keys": "node scripts/build-region-keys.mjs",
"build:region-adjacency": "node scripts/build-region-adjacency.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
+133
View File
@@ -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 ? ', …' : ''}`);
}
+158
View File
@@ -0,0 +1,158 @@
/**
* scripts/build-region-keys.mjs — build the deterministic region-key manifest.
*
* Region ownership flips from whole-country (ISO_A3) to individual sub-region
* granularity, but the LLM and the persisted `Nation.territory` list need a
* *stable, human-readable, globally unique* identifier per sub-region. Raw
* feature ids (`FR-57`) are opaque to the model, so we mint a readable key of
* the form:
*
* <name-slug>-<4char> e.g. brittany-xk2a, western-alun
*
* where:
* - <name-slug> = properties.name → NFKD → strip combining marks → lowercase
* → a tiny fold table for the handful of letters NFKD does NOT decompose
* (ə ø ð đ œ ł, the only non-folding letters present in this dataset)
* → every non-alphanumeric run collapsed to a single '-' → leading/trailing
* '-' trimmed.
* - <4char> = a deterministic 4-letter lowercase code derived from the
* feature's stable id (FNV-1a 32-bit → 4 base-26 chars). It is stable
* (same id → same code on every run) because the key is persisted in
* SQLite and shown to the LLM. Within any name-slug group (e.g. the six
* regions all named "Western") the 4-char codes are made distinct by
* re-deriving with a deterministic salt on collision, which guarantees the
* full key is unique across all 1,796 regions.
*
* Output: `public/region-keys.json` — a `{ [stableFeatureId]: regionKey }`
* object, e.g. `{ "FR-57": "brittany-xk2a" }`, in file (stable) order. The map
* is looked up by the client (`WorldMap`) and the server (`map`/`presets`/
* `engine`) to join a sub-region's stable id to its readable key.
*
* Builtins only — deliberately NO `@turf`/geojson import (importing one pulls
* `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see the
* v0.17 changelog note). Reads the geo file with plain `JSON.parse`.
*
* Idempotent: a re-run rewrites a byte-identical file (no timestamps, no
* randomness, stable feature order, deterministic hash).
*
* Usage: `node scripts/build-region-keys.mjs` (or `npm run build:region-keys`).
*/
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
/**
* Letters present in the dataset that NFKD does NOT decompose, and their
* single/multi-letter ASCII equivalents, so their slugs aren't hyphen-soup.
* Kept deliberately tiny + deterministic (only the six that actually occur).
*/
const FOLD = {
'ə': 'a', // schwa
'ø': 'o', // o-stroke
'ð': 'd', // eth
'đ': 'dj', // d-stroke
'œ': 'oe', // oe ligature
'ł': 'l', // l-stroke
};
/**
* Readable slug from a region name. NFKD decomposes + we strip the combining
* marks, lowercase, fold the non-decomposing letters, collapse every other
* non-alphanumeric run to one hyphen, and trim leading/trailing hyphens.
*/
function slug(name) {
let s = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
s = s.toLowerCase();
s = [...s].map((ch) => FOLD[ch] ?? ch).join('');
s = s.replace(/[^a-z0-9]+/g, '-');
s = s.replace(/^-+|-+$/g, '');
return s;
}
/** FNV-1a 32-bit — a tiny, stable, pure string hash. */
function fnv1a32(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return h >>> 0;
}
/** Spread a 32-bit hash across four lowercase letters (base 26 per byte). */
function code4(h) {
let out = '';
for (let i = 0; i < 4; i++) {
out += String.fromCharCode(97 + ((((h >>> (i * 8)) & 0xff) % 26)));
}
return out;
}
/** The 4-char code for an id, deterministically salted on within-group collision. */
function codeFor(id, used) {
let code = code4(fnv1a32(id));
let attempt = 0;
while (used.has(code)) {
attempt += 1;
code = code4(fnv1a32(`${id}#${attempt}`));
}
return code;
}
// --- load the region geo (plain parse; no geojson types) ---
const geoFile = path.join(root, 'public', 'regions.geo.json');
const geo = JSON.parse(readFileSync(geoFile, 'utf8'));
const features = Array.isArray(geo?.features) ? geo.features : [];
if (features.length !== 1796) {
throw new Error(`expected 1,796 regions, got ${features.length} — regions.geo.json changed; re-verify the region-key contract`);
}
// Group features by their (computed) name-slug, preserving file order, so the
// within-slug 4-char uniqueness resolution below is deterministic.
const groups = new Map(); // slug -> array of { id }
for (const f of features) {
const id = f.id == null ? '' : String(f.id);
const name = f.properties?.name;
if (!id || typeof name !== 'string') {
throw new Error(`region with id ${JSON.stringify(id)} has no usable name`);
}
const s = slug(name);
if (!groups.has(s)) groups.set(s, []);
groups.get(s).push({ id });
}
// Assign keys, resolving 4-char collisions within each slug group.
const manifest = {};
for (const [s, ids] of groups) {
const used = new Set();
for (const { id } of ids) {
manifest[id] = `${s}-${codeFor(id, used)}`;
used.add(manifest[id].slice(-4));
}
}
// --- hard assertions (the test re-checks these against the committed file) ---
const keys = Object.values(manifest);
if (new Set(keys).size !== keys.length) {
throw new Error(`region-key collision: ${keys.length} keys but only ${new Set(keys).size} unique`);
}
const KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*-[a-z]{4}$/;
for (const k of keys) {
if (!KEY_RE.test(k) || !k.includes('-')) {
throw new Error(`malformed region key: ${JSON.stringify(k)}`);
}
}
// --- write idempotently (stable order = insertion order = file order) ---
const out = JSON.stringify(manifest, null, 2) + '\n';
writeFileSync(path.join(root, 'public', 'region-keys.json'), out);
const western = Object.entries(manifest)
.filter(([, k]) => k.startsWith('western-'))
.sort((a, b) => a[0] < b[0] ? -1 : 1);
console.log(`[build-region-keys] wrote public/region-keys.json — ${keys.length} unique region keys (1,796 expected)`);
console.log(` western group: ${western.map(([id, k]) => `${id}=${k}`).join(', ')}`);
+87
View File
@@ -0,0 +1,87 @@
/**
* 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(1796);
});
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);
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* scripts/region-keys.test.ts — validation for the committed `public/region-keys.json`.
*
* The manifest maps each sub-region's **stable feature id** (from
* `public/regions.geo.json`) to a deterministic, human-readable, globally
* unique region key of the form `<name-slug>-<4char>` (built by
* `scripts/build-region-keys.mjs`). Region-level ownership (v0.19) keys both
* `Nation.territory` and the `RegionMap` on these keys, so the manifest is a
* load-bearing data artifact — this test pins its contract.
*
* Deliberately dependency-free: it reads both JSON files with plain
* `fs`/`JSON.parse` and never imports `@turf`/geojson types (importing one
* pulls `@types/geojson` into the tsc program and breaks `WorldMap.tsx`; see
* the v0.17 changelog note). Mirrors the style of `scripts/regions.test.ts`.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
function readJson(rel: string): Record<string, unknown> {
return JSON.parse(readFileSync(path.join(root, rel), 'utf8'));
}
describe('region-keys.json', () => {
const manifest = readJson('public/region-keys.json') as Record<string, string>;
const geo = readJson('public/regions.geo.json') as {
features?: Array<{ id?: string | number }>;
};
const keys = Object.values(manifest);
const featureIds = (geo.features ?? []).map((f) => String(f.id));
it('has exactly 1,796 entries (one per region)', () => {
expect(Object.keys(manifest)).toHaveLength(1796);
});
it('has 1,796 unique keys (no collisions)', () => {
expect(new Set(keys).size).toBe(keys.length);
expect(new Set(keys).size).toBe(1796);
});
it('keys each end in -[a-z]{4} and contain at least one hyphen', () => {
const ending = /-[a-z]{4}$/;
for (const k of keys) {
expect(k, `key does not end in -[a-z]{4}: ${k}`).toMatch(ending);
expect(k, `key has no hyphen: ${k}`).toContain('-');
}
});
it('is a stable join: its keys are exactly the regions.geo.json feature ids', () => {
// The manifest is looked up by feature id, so its key set must line up
// 1:1 with the geo file (catches drift if the geo is regenerated without
// rebuilding the keys).
expect(featureIds).toHaveLength(1796);
const manifestIds = Object.keys(manifest);
expect(new Set(manifestIds)).toEqual(new Set(featureIds));
});
});
+1 -6
View File
@@ -3,7 +3,6 @@ import { mkdirSync } from 'node:fs';
import path from 'node:path'; import 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,7 @@ 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 return JSON.parse(row.data) as GameState;
// sub-region ids on read. A no-op when the region geo is not loaded,
// and idempotent once a state has been migrated (the next save then
// persists the region-level territory).
return migrateTerritoryToRegions(JSON.parse(row.data) as GameState);
} catch { } catch {
return null; return null;
} }
+379 -84
View File
@@ -1,15 +1,17 @@
import { beforeAll, describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { GameState, Preset, RoundItem } from '../shared/types'; import type { GameState, Preset, RoundItem } from '../shared/types';
import { makeEvent, type TurnResult } from '../shared/protocol'; import { makeEvent, type TurnResult } from '../shared/protocol';
import type { LlmClient } from './llm'; import type { LlmClient, ToolDefinition, WireMessage } from './llm';
import { makeStubLlmClient } from './stub'; import { makeStubLlmClient } from './stub';
import { _setRegionsForTests } from './regions';
import { import {
applyTurnResult, applyTurnResult,
buildSystemPrompt, buildSystemPrompt,
buildUserPrompt, buildUserPrompt,
GET_NEIGHBOURS_TOOL,
isMeaningful, isMeaningful,
makeNeighboursExecutor,
parseTurnResult, parseTurnResult,
playerFrontier,
queueRoundItem, queueRoundItem,
removeRoundItem, removeRoundItem,
runTurn, runTurn,
@@ -25,27 +27,6 @@ function fakeLlm(responses: string[]): LlmClient {
}; };
} }
// Give the region module a small, deterministic index so `buildUserPrompt`'s
// `regions` reference table is populated in these fast unit tests (the real
// 3.4 MB geo stays out of this file). The ids match the synthetic territories
// below.
beforeAll(() => {
_setRegionsForTests(
{
'GB-01': { name: 'Greater London', parent: 'GBR' },
'GB-02': { name: 'Scotland', parent: 'GBR' },
'US-01': { name: 'New England', parent: 'USA' },
'US-02': { name: 'The Midwest', parent: 'USA' },
'PL-01': { name: 'Pomerania', parent: 'POL' },
'DE-01': { name: 'Rhineland', parent: 'DEU' },
},
{ GBR: ['GB-01', 'GB-02'], USA: ['US-01', 'US-02'], POL: ['PL-01'], DEU: ['DE-01'] },
);
});
// As of v0.19 territory is a list of **sub-region ids** (the RegionMap keys),
// not whole-country ISO_A3 codes. `us` also holds `PL-01` so that a canned or
// stubbed gain of that region has a prior owner to move it from.
const preset: Preset = { const preset: Preset = {
id: 'test', id: 'test',
title: 'Test', title: 'Test',
@@ -59,7 +40,7 @@ const preset: Preset = {
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: ['GBR'],
relations: { us: 'neutral', de: 'hostile' }, relations: { us: 'neutral', de: 'hostile' },
}, },
{ {
@@ -67,7 +48,7 @@ const preset: Preset = {
name: 'US', name: 'US',
type: 'major', type: 'major',
stats: { military: 60, economy: 90, stability: 80, population: 80 }, stats: { military: 60, economy: 90, stability: 80, population: 80 },
territory: ['US-01', 'US-02', 'PL-01'], territory: ['USA'],
relations: { uk: 'neutral' }, relations: { uk: 'neutral' },
}, },
{ {
@@ -75,7 +56,7 @@ const preset: Preset = {
name: 'DE', name: 'DE',
type: 'major', type: 'major',
stats: { military: 95, economy: 70, stability: 75, population: 90 }, stats: { military: 95, economy: 70, stability: 75, population: 90 },
territory: ['DE-01'], territory: ['DEU'],
relations: {}, relations: {},
}, },
], ],
@@ -110,7 +91,10 @@ const validResult = JSON.stringify({
{ nation: 'de', stat: 'economy', op: 'add', value: 10 }, { nation: 'de', stat: 'economy', op: 'add', value: 10 },
], ],
relations: [{ a: 'uk', b: 'us', set: 'ally' }], relations: [{ a: 'uk', b: 'us', set: 'ally' }],
territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }], // v0.21 (#16 P2): DE gains the UK's GBR — the key has exactly one previous
// owner, so the transfer applies (an unowned key like 'POL' would be ignored
// by the new sole-ownership rules in applyTurnResult).
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
}); });
describe('runTurn', () => { describe('runTurn', () => {
@@ -131,9 +115,10 @@ describe('runTurn', () => {
// relations are symmetric // relations are symmetric
expect(state.nations.uk.relations.us).toBe('ally'); expect(state.nations.uk.relations.us).toBe('ally');
expect(state.nations.us.relations.uk).toBe('ally'); expect(state.nations.us.relations.uk).toBe('ally');
// the region moved to DE, out of its prior owner (us) // territory moved to DE: it gains GBR (the UK's sole previous owner) and
expect(state.nations.de.territory).toContain('PL-01'); // the old owner correspondingly loses it (v0.21 #16 P2: one owner per key)
expect(state.nations.us.territory).not.toContain('PL-01'); expect(state.nations.de.territory).toContain('GBR');
expect(state.nations.uk.territory).not.toContain('GBR');
// one event appended, tagged with the turn that was resolved (1) // one event appended, tagged with the turn that was resolved (1)
expect(state.events).toHaveLength(1); expect(state.events).toHaveLength(1);
expect(state.events[0].turn).toBe(1); expect(state.events[0].turn).toBe(1);
@@ -144,8 +129,8 @@ describe('runTurn', () => {
// and the original state object is NOT mutated // and the original state object is NOT mutated
expect(original.turn).toBe(1); expect(original.turn).toBe(1);
expect(original.nations.uk.stats.military).toBe(80); expect(original.nations.uk.stats.military).toBe(80);
expect(original.nations.us.territory).toContain('PL-01'); expect(original.nations.de.territory).not.toContain('GBR');
expect(original.nations.de.territory).not.toContain('PL-01'); expect(original.nations.uk.territory).toContain('GBR');
}); });
it('clamps out-of-range stat values into 0-100', async () => { it('clamps out-of-range stat values into 0-100', async () => {
@@ -215,7 +200,7 @@ describe('runTurn', () => {
}); });
describe('runTurn (batched round, deterministic stub)', () => { describe('runTurn (batched round, deterministic stub)', () => {
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5), one deterministic region gain', async () => { it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5)', async () => {
const original = baseState(); const original = baseState();
original.pending = [{ kind: 'action', text: 'Fortify the coast and mobilize the home guard' }]; original.pending = [{ kind: 'action', text: 'Fortify the coast and mobilize the home guard' }];
const { state, result } = await runTurn({ const { state, result } = await runTurn({
@@ -233,12 +218,6 @@ describe('runTurn (batched round, deterministic stub)', () => {
expect(state.events[0].nation).toBe('uk'); expect(state.events[0].nation).toBe('uk');
expect(state.events[0].outcome).toBe('success'); expect(state.events[0].outcome).toBe('success');
expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]); expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]);
// the stub's one deterministic per-turn territory move: the player annexes
// the lexicographically-first region of the first other nation (us holds
// PL-01, US-01, US-02 → PL-01 wins), moved out of us.
expect(result.territoryChanges).toEqual([{ nation: 'uk', gain: ['PL-01'], lose: [] }]);
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
expect(state.nations.us.territory).toEqual(['US-01', 'US-02']);
// committing a turn is a new revision too // committing a turn is a new revision too
expect(state.version).toBe(2); expect(state.version).toBe(2);
// the original object is not mutated (a fresh queue was consumed) // the original object is not mutated (a fresh queue was consumed)
@@ -246,7 +225,7 @@ describe('runTurn (batched round, deterministic stub)', () => {
expect(original.version).toBe(1); expect(original.version).toBe(1);
}); });
it('an empty round: turn advances, one political "world moves on" event, no territory move', async () => { it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
const { state } = await runTurn({ const { state } = await runTurn({
llm: makeStubLlmClient(), llm: makeStubLlmClient(),
preset, preset,
@@ -260,13 +239,9 @@ describe('runTurn (batched round, deterministic stub)', () => {
expect(state.events[0].type).toBe('political'); expect(state.events[0].type).toBe('political');
expect(state.events[0].title).toBe('The world moves on'); expect(state.events[0].title).toBe('The world moves on');
expect(state.events[0].outcome).toBe('neutral'); expect(state.events[0].outcome).toBe('neutral');
// an empty round makes no move: every territory is untouched
expect(state.nations.uk.territory).toEqual(['GB-01', 'GB-02']);
expect(state.nations.us.territory).toEqual(['US-01', 'US-02', 'PL-01']);
expect(state.nations.de.territory).toEqual(['DE-01']);
}); });
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance, one region gain', async () => { it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance', async () => {
const state = baseState(); const state = baseState();
state.pending = [ state.pending = [
{ kind: 'action', text: 'Reinforce the Atlantic convoys' }, { kind: 'action', text: 'Reinforce the Atlantic convoys' },
@@ -285,9 +260,6 @@ describe('runTurn (batched round, deterministic stub)', () => {
expect(next.events.filter((e) => e.type === 'military')).toHaveLength(2); expect(next.events.filter((e) => e.type === 'military')).toHaveLength(2);
expect(next.events.filter((e) => e.type === 'diplomatic')).toHaveLength(1); expect(next.events.filter((e) => e.type === 'diplomatic')).toHaveLength(1);
expect(next.nations.uk.stats.military).toBe(85); // one +5 per turn, not per item expect(next.nations.uk.stats.military).toBe(85); // one +5 per turn, not per item
// …and one (not three) territory move for the whole batch
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'PL-01']);
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']);
}); });
}); });
@@ -301,7 +273,6 @@ describe('buildUserPrompt (batched round)', () => {
pending: RoundItem[]; pending: RoundItem[];
playerActions: unknown[]; playerActions: unknown[];
roundInstruction: string; roundInstruction: string;
regions: Record<string, string>;
}; };
expect(parsed.pending).toEqual(items); expect(parsed.pending).toEqual(items);
expect(parsed.playerActions).toEqual(['Fortify Dover', { to: 'us', message: 'Propose a pact' }]); expect(parsed.playerActions).toEqual(['Fortify Dover', { to: 'us', message: 'Propose a pact' }]);
@@ -309,20 +280,6 @@ describe('buildUserPrompt (batched round)', () => {
expect(parsed.roundInstruction).toContain('Propose a pact'); expect(parsed.roundInstruction).toContain('Propose a pact');
}); });
it('emits a regions reference table ("id → name (parent)") covering every territory id in the state', () => {
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
regions: Record<string, string>;
state: { nations: Record<string, { territory: string[] }> };
};
// the table is keyed by the union of all nations' (sub-region) territory ids
const allIds = Object.values(parsed.state.nations).flatMap((n) => n.territory);
expect(Object.keys(parsed.regions).sort()).toEqual([...allIds].sort());
// each id maps to a human "name (parent)" label from the region index
expect(parsed.regions['GB-01']).toBe('Greater London (GBR)');
expect(parsed.regions['DE-01']).toBe('Rhineland (DEU)');
expect(parsed.regions['PL-01']).toBe('Pomerania (POL)');
});
it('frames an empty round as "the world advances on its own momentum"', () => { it('frames an empty round as "the world advances on its own momentum"', () => {
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as { const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
pending: RoundItem[]; pending: RoundItem[];
@@ -370,6 +327,17 @@ describe('buildUserPrompt (batched round)', () => {
it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => { it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => {
expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i); expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i);
}); });
it('includes a regions reference table only when one is provided', () => {
// with a reference table: the payload carries it (region key -> "name (parent)")
const withRef = JSON.parse(
buildUserPrompt(baseState(), [], { 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' }),
) as { regions?: Record<string, string> };
expect(withRef.regions).toEqual({ 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' });
// without one: the field is absent entirely (the legacy prompt shape is unchanged)
const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> };
expect(withoutRef.regions).toBeUndefined();
});
}); });
describe('queueRoundItem', () => { describe('queueRoundItem', () => {
@@ -514,46 +482,373 @@ describe('applyTurnResult', () => {
expect(state.version).toBe(1); expect(state.version).toBe(1);
}); });
it('moves a gained region out of its prior owner (region-level transfer)', () => { it('with a validKeys set: applies real region keys, ignores hallucinated ones', () => {
const state = baseState(); const state = baseState();
// give the fixture's uk a real key ('wales-bbbb') for DE to be able to take
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
const result: TurnResult = { const result: TurnResult = {
date: '1939-09-03', date: '1939-09-03',
events: [], events: [],
stateChanges: [], stateChanges: [],
relations: [], relations: [],
territoryChanges: [{ nation: 'de', gain: ['PL-01'], lose: [] }], // 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz'
// is not a real key at all. Both are proposed as a gain by DE.
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
expect(next.nations.de.territory).toContain('PL-01'); // the valid key was gained (taken from its sole previous owner); the bogus one was ignored
expect(next.nations.us.territory).not.toContain('PL-01'); // spliced out of the prior owner expect(next.nations.de.territory).toContain('wales-bbbb');
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02']); expect(next.nations.uk.territory).not.toContain('wales-bbbb');
// the bogus key never entered any nation's territory
for (const n of Object.values(next.nations)) {
expect(n.territory).not.toContain('not-a-region-xyz');
}
}); });
it('adds a region nobody owned (no prior owner to splice from)', () => { it('without a validKeys set: applies every listed key (legacy/unit path)', () => {
const state = baseState(); const state = baseState();
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
const result: TurnResult = { const result: TurnResult = {
date: '1939-09-03', date: '1939-09-03',
events: [], events: [],
stateChanges: [], stateChanges: [],
relations: [], relations: [],
territoryChanges: [{ nation: 'uk', gain: ['FR-57'], lose: [] }], // 'wales-bbbb' has a sole previous owner (uk) -> it transfers; 'POL' is
// owned by no one -> it is IGNORED (a key may not end a turn unowned).
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.uk.territory).toEqual(['GB-01', 'GB-02', 'FR-57']); expect(next.nations.de.territory).toContain('wales-bbbb');
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
expect(next.nations.de.territory).not.toContain('POL');
});
});
describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
const noEvents = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
};
it('a double claim (two different gainers, one key) is rejected: the key stays with its original owner', () => {
const state = baseState();
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [
{ nation: 'de', gain: ['wales-bbbb'], lose: [] },
{ nation: 'us', gain: ['wales-bbbb'], lose: [] },
],
});
expect(next.nations.uk.territory).toContain('wales-bbbb');
expect(next.nations.de.territory).not.toContain('wales-bbbb');
expect(next.nations.us.territory).not.toContain('wales-bbbb');
}); });
it('drops a lost region with no gainer (it becomes unowned / no-man\'s-land)', () => { it('duplicate gain entries for the SAME nation are deduped (one claim, one copy)', () => {
const state = baseState(); const state = baseState();
const result: TurnResult = { state.nations.uk.territory = ['GBR', 'wales-bbbb'];
date: '1939-09-03', const next = applyTurnResult(state, {
events: [], ...noEvents,
stateChanges: [], territoryChanges: [
relations: [], { nation: 'de', gain: ['wales-bbbb'], lose: [] },
territoryChanges: [{ nation: 'us', gain: [], lose: ['PL-01'] }], { nation: 'de', gain: ['wales-bbbb'], lose: [] },
],
});
expect(next.nations.de.territory.filter((k) => k === 'wales-bbbb')).toHaveLength(1);
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
});
it('a lone "lose" is a no-op: the key cannot be orphaned (zero owners)', () => {
const state = baseState();
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'uk', gain: [], lose: ['GBR'] }],
});
expect(next.nations.uk.territory).toContain('GBR');
});
it('a self-gain is a no-op (a nation keeps exactly one copy of its own key)', () => {
const state = baseState();
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'uk', gain: ['GBR'], lose: [] }],
});
expect(next.nations.uk.territory.filter((k) => k === 'GBR')).toHaveLength(1);
});
it('a pre-existing double ownership is never touched (a key with 2 owners keeps 2)', () => {
const state = baseState();
state.nations.us.territory = ['USA', 'GBR']; // uk AND us already share GBR
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
});
expect(next.nations.uk.territory).toContain('GBR');
expect(next.nations.us.territory).toContain('GBR');
expect(next.nations.de.territory).not.toContain('GBR');
});
it('ownership is conserved across a transfer: exactly one owner before and after', () => {
const state = baseState();
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
const count = (s: GameState) =>
Object.values(s.nations).reduce(
(acc, n) => acc + n.territory.filter((k) => k === 'wales-bbbb').length,
0,
);
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb'], lose: [] }],
});
expect(count(state)).toBe(1);
expect(count(next)).toBe(1);
expect(next.nations.de.territory).toContain('wales-bbbb');
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
});
});
describe('makeNeighboursExecutor (v0.21 #16 P2)', () => {
const getNeighbours = (keys: string[]) => keys.map((k) => `nb-${k}`);
it('answers getNeighbours with the resolver output', () => {
const exec = makeNeighboursExecutor(getNeighbours);
expect(exec('getNeighbours', { regionKeys: ['a', 'b'] })).toBe(
JSON.stringify({ neighbours: ['nb-a', 'nb-b'] }),
);
});
it('degrades unknown tools, malformed args, and unknown keys without throwing', () => {
const exec = makeNeighboursExecutor(getNeighbours, new Set(['a']));
expect(exec('otherTool', {})).toBe(JSON.stringify({ error: 'unknown tool: otherTool' }));
expect(exec('getNeighbours', { regionKeys: 'not-an-array' })).toBe(
JSON.stringify({ neighbours: [] }),
);
expect(exec('getNeighbours', { regionKeys: ['a', 'zzz-not-valid'] })).toBe(
JSON.stringify({ neighbours: ['nb-a'] }), // the unknown key is filtered out
);
});
});
describe('playerFrontier (v0.21 #16 P2)', () => {
const adjacency: Record<string, string[]> = {
GBR: ['DEU', 'USA'],
GBR2: ['DEU'],
DEU: ['GBR', 'GBR2', 'USA'],
USA: ['GBR', 'DEU'],
};
const getNeighbours = (keys: string[]) =>
[...new Set(keys.flatMap((k) => adjacency[k] ?? []))]
.filter((k) => !keys.includes(k))
.sort();
it('is the sorted, deduped set of other-nation keys bordering the player', () => {
const state = baseState();
state.nations.uk.territory = ['GBR', 'GBR2']; // the player owns both
// DEU (de) and USA (us) border the player; deduped and sorted
expect(playerFrontier(state, getNeighbours)).toEqual(['DEU', 'USA']);
});
it('drops candidates the player itself owns (a sloppy resolver may report them)', () => {
const state = baseState();
state.nations.uk.territory = ['GBR', 'GBR2'];
const sloppy = () => ['GBR2', 'DEU'];
expect(playerFrontier(state, sloppy)).toEqual(['DEU']);
});
it('is empty when the player has no territory or no controllable border', () => {
const empty = baseState();
empty.nations.uk.territory = [];
expect(playerFrontier(empty, getNeighbours)).toEqual([]);
const noBorders = () => [];
expect(playerFrontier(baseState(), noBorders)).toEqual([]);
});
});
describe('runTurn (getNeighbours tool path, v0.21 #16 P2)', () => {
const adjacency: Record<string, string[]> = {
GBR: ['DEU'],
DEU: ['GBR', 'USA'],
USA: ['DEU'],
};
const getNeighbours = (keys: string[]) =>
[...new Set(keys.flatMap((k) => adjacency[k] ?? []))]
.filter((k) => !keys.includes(k))
.sort();
const finalTurn = JSON.stringify({
date: '1939-09-03',
events: [
{
nation: 'uk',
type: 'military',
title: 'The border region falls',
text: 'UK seizes the contiguous region.',
outcome: 'success',
},
],
stateChanges: [],
relations: [],
// the player gains its only frontier region (DEU, owned by de)
territoryChanges: [{ nation: 'uk', gain: ['DEU'], lose: [] }],
});
/**
* A fake LlmClient implementing BOTH chat() and chatWithTools(). The
* chatWithTools fake plays the model's side of the protocol: it records
* the initial messages + tools, executes the scripted tool calls through
* the executor the engine handed it, and returns the final content.
*/
function toolLlm(final: string, scriptedCalls: Array<{ name: string; args: Record<string, unknown> }>) {
const seen: {
firstMessages: WireMessage[];
tools: ToolDefinition[];
executorCalls: Array<{ name: string; args: Record<string, unknown>; result: string }>;
chatCalls: number;
} = { firstMessages: [], tools: [], executorCalls: [], chatCalls: 0 };
const client: LlmClient = {
chat: async (messages) => {
seen.chatCalls += 1;
seen.firstMessages = messages as WireMessage[];
return final;
},
listModels: async () => [],
chatWithTools: async (messages, tools, executeTool) => {
seen.firstMessages = messages;
seen.tools = tools;
for (const call of scriptedCalls) {
seen.executorCalls.push({ ...call, result: executeTool(call.name, call.args) });
}
return final;
},
}; };
const next = applyTurnResult(state, result); return { client, seen };
expect(next.nations.us.territory).toEqual(['US-01', 'US-02']); }
expect(Object.values(next.nations).every((n) => !n.territory.includes('PL-01'))).toBe(true);
it('uses chatWithTools with the tool, a frontier in the prompt, the wired executor, and applies the transfer', async () => {
const original = baseState();
original.pending = [{ kind: 'action', text: 'Seize the border region' }];
const { client, seen } = toolLlm(finalTurn, [
{ name: 'getNeighbours', args: { regionKeys: ['GBR'] } },
]);
const { state } = await runTurn({
llm: client,
preset,
state: original,
items: original.pending,
getNeighbours,
});
// the tool was offered and the system prompt advertises it
expect(seen.tools.map((t) => t.function.name)).toEqual([GET_NEIGHBOURS_TOOL.function.name]);
const system = seen.firstMessages[0] as { role: 'system'; content: string };
expect(system.content).toContain('getNeighbours');
// the user payload carries the player's frontier (DEU: the player's only
// controllable border, owned by de)
const user = seen.firstMessages[1] as { role: 'user'; content: string };
const payload = JSON.parse(user.content) as { frontier?: string[] };
expect(payload.frontier).toEqual(['DEU']);
// the executor the engine built answers from the resolver
expect(seen.executorCalls).toHaveLength(1);
expect(seen.executorCalls[0].args).toEqual({ regionKeys: ['GBR'] });
expect(seen.executorCalls[0].result).toBe(JSON.stringify({ neighbours: ['DEU'] }));
// chat() was never used on the tool path
expect(seen.chatCalls).toBe(0);
// the contiguous transfer was applied: uk gains DEU, de loses it
expect(state.nations.uk.territory).toContain('DEU');
expect(state.nations.de.territory).not.toContain('DEU');
});
it('falls back to plain chat() when the client lacks chatWithTools (no tool; the frontier hint stays in the prompt)', async () => {
const original = baseState();
const { client, seen } = toolLlm(validResult, []);
delete client.chatWithTools; // simulate a minimal client (chat only)
const { state } = await runTurn({
llm: client,
preset,
state: original,
items: original.pending,
getNeighbours,
});
expect(seen.chatCalls).toBe(1);
expect(seen.tools).toEqual([]); // the tool path was never entered
const system = seen.firstMessages[0] as { content: string };
expect(system.content).not.toContain('getNeighbours');
const user = seen.firstMessages[1] as { content: string };
const payload = JSON.parse(user.content) as { frontier?: string[] };
// the frontier hint stays (a resolver was given) even without the tool —
// it also tells the model which borders are realistically contestable
expect(payload.frontier).toEqual(['DEU']);
// the canned result still applied (GBR moved uk -> de)
expect(state.nations.de.territory).toContain('GBR');
expect(state.nations.uk.territory).not.toContain('GBR');
});
});
describe('deterministic stub (one contiguous conquest per turn, v0.21 #16 P2)', () => {
const adjacency: Record<string, string[]> = {
GBR: ['DEU'],
DEU: ['GBR', 'USA'],
USA: ['DEU'],
};
const getNeighbours = (keys: string[]) =>
[...new Set(keys.flatMap((k) => adjacency[k] ?? []))]
.filter((k) => !keys.includes(k))
.sort();
it('transfers the first frontier region to the player (and its owner loses it)', async () => {
const original = baseState();
original.pending = [{ kind: 'action', text: 'March on the border region' }];
const { state } = await runTurn({
llm: makeStubLlmClient(),
preset,
state: original,
items: original.pending,
getNeighbours,
});
expect(state.nations.uk.territory).toContain('DEU'); // frontier[0]
expect(state.nations.de.territory).not.toContain('DEU');
// the rest of the stub behavior is unchanged
expect(state.turn).toBe(2);
expect(state.date).toBe('1939-09-03');
expect(state.nations.uk.stats.military).toBe(85); // clamp(80 + 5)
expect(state.events).toHaveLength(1);
});
it('with an empty frontier (no controllable border, like the UK in wwii-1939): no transfer, the turn still advances', async () => {
const original = baseState();
original.pending = [{ kind: 'action', text: 'Hold the line' }];
const { state } = await runTurn({
llm: makeStubLlmClient(),
preset,
state: original,
items: original.pending,
getNeighbours: () => [], // no borders at all
});
expect(state.nations.uk.territory).toEqual(['GBR']); // untouched
expect(state.nations.de.territory).toEqual(['DEU']);
expect(state.nations.us.territory).toEqual(['USA']);
expect(state.turn).toBe(2);
expect(state.events).toHaveLength(1);
});
});
describe('buildSystemPrompt (tool mode, v0.21 #16 P2)', () => {
it('mentions the getNeighbours tool and the contiguity rules only when the tool is sent', () => {
const plain = buildSystemPrompt(preset);
expect(plain).not.toContain('getNeighbours');
expect(plain).toMatch(/Only change borders when war/i); // the legacy rule stays
const withTool = buildSystemPrompt(preset, true);
expect(withTool).toContain('getNeighbours');
expect(withTool).toMatch(/contiguous/i);
expect(withTool).toMatch(/frontier/i);
expect(withTool).not.toMatch(/Only change borders when war/i);
}); });
}); });
+242 -47
View File
@@ -22,22 +22,132 @@ import {
type TurnEventSpec, type TurnEventSpec,
type TurnResult, type TurnResult,
} from '../shared/protocol'; } from '../shared/protocol';
import { DEFAULT_MAX_TOKENS, type LlmClient } from './llm'; import {
DEFAULT_MAX_TOKENS,
type ChatMessage,
type LlmClient,
type ToolDefinition,
type ToolExecutor,
type WireMessage,
} from './llm';
import { ADVISOR_MARKER } from './stub'; import { ADVISOR_MARKER } from './stub';
import { regionNamesFor } from './regions';
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES); const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
const VALID_RELATIONS = new Set<string>(RELATION_STATUSES); const VALID_RELATIONS = new Set<string>(RELATION_STATUSES);
const VALID_OUTCOMES = new Set<string>(['success', 'failure', 'partial', 'neutral']); const VALID_OUTCOMES = new Set<string>(['success', 'failure', 'partial', 'neutral']);
const VALID_STATS = new Set<string>(['military', 'economy', 'stability', 'population']); const VALID_STATS = new Set<string>(['military', 'economy', 'stability', 'population']);
// --- The LLM's getNeighbours tool (v0.21, #16 P2: contiguous region transfers) ---
/**
* The single tool the turn loop exposes to the model: the precomputed region
* adjacency manifest (public/region-adjacency.json, via the resolver injected
* into runTurn). Given a set of region keys it returns the union of their
* bordering keys (the queried keys themselves excluded, sorted for
* determinism) — so the model can verify that a territory transfer is
* *contiguous* (a nation gains a region it already abuts) before committing
* it, instead of moving whole countries at a whim.
*/
export const GET_NEIGHBOURS_TOOL: ToolDefinition = {
type: 'function',
function: {
name: 'getNeighbours',
description:
'List the region keys that share a land border with any of the given region keys. ' +
'Pass a nation\'s territory keys (or a single region key) to see which regions are ' +
'contiguous to it. Use it before transferring territory: a nation can realistically ' +
'only gain regions that border its existing territory.',
parameters: {
type: 'object',
properties: {
regionKeys: {
type: 'array',
items: { type: 'string' },
description: 'One or more region keys to query (e.g. a gaining nation\'s territory).',
},
},
required: ['regionKeys'],
additionalProperties: false,
},
},
};
/**
* Build the tool executor for the turn loop. Never throws: unknown tool
* names, malformed arguments, and unknown region keys all degrade to a safe
* response (an error object or an empty list) that the model can recover from.
*/
export function makeNeighboursExecutor(
getNeighbours: (keys: string[]) => string[],
validRegionKeys?: ReadonlySet<string>,
): ToolExecutor {
return (name, args) => {
if (name !== GET_NEIGHBOURS_TOOL.function.name) {
return JSON.stringify({ error: `unknown tool: ${name}` });
}
const raw = Array.isArray(args.regionKeys) ? args.regionKeys : [];
const keys = raw.filter((k): k is string => typeof k === 'string' && k.length > 0);
const valid = validRegionKeys ? keys.filter((k) => validRegionKeys.has(k)) : keys;
if (valid.length === 0) return JSON.stringify({ neighbours: [] });
return JSON.stringify({ neighbours: getNeighbours(valid) });
};
}
/**
* The player's frontier: region keys owned by *other* nations that share a
* land border with the player's territory (deduped, sorted — stable order for
* the deterministic stub). Computed server-side from the adjacency manifest so
* both the LLM (the `frontier` field of the user prompt) and the stub (which
* cannot call tools) see the same candidates. Empty when there is no
* adjacency data or no controllable border (e.g. the UK in 1939).
*/
export function playerFrontier(
state: GameState,
getNeighbours: (keys: string[]) => string[],
): string[] {
const player = state.nations[state.playerNationId];
if (!player || player.territory.length === 0) return [];
const playerKeys = new Set(player.territory);
const candidates = new Set<string>();
for (const key of getNeighbours(player.territory)) {
if (!playerKeys.has(key)) candidates.add(key);
}
if (candidates.size === 0) return [];
const frontier: string[] = [];
for (const n of Object.values(state.nations)) {
if (n.id === state.playerNationId) continue;
for (const key of n.territory) {
if (candidates.has(key)) {
candidates.delete(key);
frontier.push(key);
}
}
}
return frontier.sort();
}
// --- Prompt building ---------------------------------------------------------- // --- Prompt building ----------------------------------------------------------
/** The LLM system prompt: world context + rules + a strict JSON contract. */ /**
export function buildSystemPrompt(preset: Preset): string { * The LLM system prompt: world context + rules + a strict JSON contract.
* When `withNeighboursTool` is set (the turn runs with the getNeighbours tool
* available) the contract gains the contiguity rules for territory transfers
* (v0.21, #16 P2): the model may only move regions across a shared border, and
* may verify borders with the tool before answering.
*/
export function buildSystemPrompt(preset: Preset, withNeighboursTool = false): string {
const rules = preset.simulationRules const rules = preset.simulationRules
.map((r) => `- ${r}`) .map((r) => `- ${r}`)
.join('\n'); .join('\n');
const respondLine = withNeighboursTool
? 'You have a getNeighbours tool: given region keys it returns the region keys that border them. You may call it (a few times) to verify land borders before deciding territory transfers. Once done, respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:'
: 'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:';
const territoryRules = withNeighboursTool
? [
'- Territory changes must be contiguous: a nation may only GAIN region keys that share a land border with a region it already owns (verify with getNeighbours) and may only LOSE region keys it currently owns. One region key has exactly one owner at all times — never assign a key to two nations or to none.',
'- The "frontier" field of the user message lists region keys owned by other nations that border the player\'s territory — the natural targets of a player-led conquest this turn.',
]
: ['- Only change borders when war or a decisive political shift justifies it.'];
return [ return [
`WORLD: ${preset.title} — ${preset.startDate}`, `WORLD: ${preset.title} — ${preset.startDate}`,
preset.worldBackground, preset.worldBackground,
@@ -46,7 +156,7 @@ export function buildSystemPrompt(preset: Preset): string {
rules, rules,
'', '',
'You are the world simulator. You advance the shared world state for ALL nations given the current state and the player\'s move.', 'You are the world simulator. You advance the shared world state for ALL nations given the current state and the player\'s move.',
'Respond with ONLY a valid JSON object of exactly this shape. No prose, no commentary, no markdown fences, no trailing commas:', respondLine,
'{', '{',
' "date": "YYYY-MM-DD (a few days after the current date)",', ' "date": "YYYY-MM-DD (a few days after the current date)",',
' "events": [', ' "events": [',
@@ -54,16 +164,16 @@ export function buildSystemPrompt(preset: Preset): string {
' ],', ' ],',
' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],', ' "stateChanges": [{"nation":"<nationId>","stat":"<military|economy|stability|population>","op":"<set|add>","value":<0-100>}],',
' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],', ' "relations": [{"a":"<nationId>","b":"<nationId>","set":"<ally|neutral|rival|hostile|war>"}],',
' "territoryChanges": [{"nation":"<nationId>","gain":["<regionId>"],"lose":["<regionId>"]}]', ' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}]',
'}', '}',
'', '',
'HARD CONSTRAINTS', 'HARD CONSTRAINTS',
'- Use only nation ids and sub-region ids (e.g. `FR-57`) that exist in the provided state.', '- Use only nation ids and region keys that exist in the provided state.',
'- A region id (e.g. `FR-57`) identifies a single sub-region — the `regions` table below lists each id with its name and country; a nation\'s border can change one region at a time.', '- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges.',
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.', '- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.', '- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
'- Keep every stat value between 0 and 100.', '- Keep every stat value between 0 and 100.',
'- Only change borders when war or a decisive political shift justifies it.', ...territoryRules,
'- If a field is not relevant this turn, set it to an empty array.', '- If a field is not relevant this turn, set it to an empty array.',
].join('\n'); ].join('\n');
} }
@@ -77,7 +187,18 @@ export function buildSystemPrompt(preset: Preset): string {
* model to simulate the combined consequences of the whole batch (or to let the * model to simulate the combined consequences of the whole batch (or to let the
* world advance on its own when the batch is empty) and return one TurnResult. * world advance on its own when the batch is empty) and return one TurnResult.
*/ */
export function buildUserPrompt(state: GameState, items: RoundItem[]): string { export function buildUserPrompt(
state: GameState,
items: RoundItem[],
regionReference?: Record<string, string>,
/**
* The player's frontier: region keys owned by other nations that border the
* player's territory (v0.21, #16 P2). Emitted verbatim (even when empty) so
* the model — and the deterministic stub, which reads it — see the same
* contiguous targets. Omitted entirely when undefined (no adjacency data).
*/
frontier?: string[],
): string {
const player = state.nations[state.playerNationId]; const player = state.nations[state.playerNationId];
const playerName = player?.name ?? state.playerNationId; const playerName = player?.name ?? state.playerNationId;
const playerActions = items.map((i) => const playerActions = items.map((i) =>
@@ -90,7 +211,7 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.` )}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.'; : 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
const payload = { const payload: Record<string, unknown> = {
state: { state: {
turn: state.turn, turn: state.turn,
date: state.date, date: state.date,
@@ -108,19 +229,12 @@ export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
outcome: e.outcome, outcome: e.outcome,
})), })),
}, },
// A name lookup for every region id referenced anywhere in the state, so the
// model can map an id it is considering (e.g. `FR-57`) to a place it
// recognizes. This is a top-level reference table keyed by sub-region id;
// each nation's own `territory` still carries raw ids (via nationForPrompt,
// unchanged) so the deterministic stub can parse them. When the region geo
// is not loaded the table is empty and the model falls back to the raw ids.
regions: regionNamesFor(
Object.values(state.nations).flatMap((n) => n.territory),
),
playerActions, playerActions,
pending: items, pending: items,
roundInstruction, roundInstruction,
}; };
if (regionReference) payload.regions = regionReference;
if (frontier !== undefined) payload.frontier = frontier;
return JSON.stringify(payload, null, 2); return JSON.stringify(payload, null, 2);
} }
@@ -294,7 +408,7 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
* Apply a TurnResult to a GameState immutably: advance the date/turn, append * Apply a TurnResult to a GameState immutably: advance the date/turn, append
* events, apply stat changes (clamped), symmetric relations, and territory moves. * events, apply stat changes (clamped), symmetric relations, and territory moves.
*/ */
export function applyTurnResult(state: GameState, result: TurnResult): GameState { export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): GameState {
const next: GameState = JSON.parse(JSON.stringify(state)); const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision next.version = (state.version ?? 0) + 1; // every mutation is a new revision
const actingTurn = state.turn; // this action resolves the current turn const actingTurn = state.turn; // this action resolves the current turn
@@ -317,21 +431,64 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
a.relations[r.b] = r.set; a.relations[r.b] = r.set;
b.relations[r.a] = r.set; b.relations[r.a] = r.set;
} }
for (const t of result.territoryChanges) { // --- Territory (v0.21, #16 P2: a region key has exactly one owner) ---
const n = next.nations[t.nation]; //
if (!n) continue; // Snapshot-first: BEFORE any mutation, compute who currently owns each
for (const code of t.gain) { // region key. A key transfers only when (a) exactly one nation claims to
for (const other of Object.values(next.nations)) { // gain it — a double claim (two or more gainers) is rejected so the key
const i = other.territory.indexOf(code); // stays where it is, (b) the key currently has exactly one owner — a
if (i !== -1) other.territory.splice(i, 1); // pre-existing double ownership (or unownership) is never touched, and
} // (c) that owner is not the gainer (a self-gain is a no-op). A transfer
if (!n.territory.includes(code)) n.territory.push(code); // removes the key from every nation first (defensive) and then grants it
} // to the gainer; "lose" entries are pure no-ops by design, because a lone
for (const code of t.lose) { // loss would orphan the key and a valid gain already removes it from the
const i = n.territory.indexOf(code); // old owner. Ownership is therefore conserved: a key can never end a turn
if (i !== -1) n.territory.splice(i, 1); // unowned or multiply-owned.
const valid = (k: string) => !validKeys || validKeys.has(k);
const ownerOf = new Map<string, string>(); // key -> sole owner id, or '' (0 or 2+ owners)
for (const n of Object.values(state.nations)) {
for (const key of n.territory) {
if (!valid(key)) continue;
const cur = ownerOf.get(key);
if (cur === undefined) ownerOf.set(key, n.id);
else if (cur !== '') ownerOf.set(key, '');
} }
} }
// Claim pairs, deduped: one (key, gainer) per pair even if the model emits
// duplicate entries; two DIFFERENT gainers for one key is a double claim.
const claimPairs = new Set<string>();
for (const t of result.territoryChanges) {
if (!next.nations[t.nation]) continue;
for (const key of new Set(t.gain)) {
if (!valid(key)) continue;
claimPairs.add(`${key}\u0000${t.nation}`);
}
}
const claimants = new Map<string, Set<string>>(); // key -> distinct gainers
for (const pair of claimPairs) {
const idx = pair.indexOf('\u0000');
const key = pair.slice(0, idx);
const gainer = pair.slice(idx + 1);
let set = claimants.get(key);
if (set === undefined) {
set = new Set();
claimants.set(key, set);
}
set.add(gainer);
}
for (const [key, claiming] of claimants) {
if (claiming.size !== 1) continue; // double claim: the key stays as it was
const gainer = [...claiming][0];
const owner = ownerOf.get(key);
if (owner === undefined || owner === '' || owner === gainer) continue;
for (const n of Object.values(next.nations)) {
const i = n.territory.indexOf(key);
if (i !== -1) n.territory.splice(i, 1);
}
next.nations[gainer].territory.push(key);
}
// "lose" entries are deliberate no-ops (see above): a valid gain already
// removes the key from its old owner, and a lone loss would orphan it.
return next; return next;
} }
@@ -367,36 +524,74 @@ export function removeRoundItem(state: GameState, index: number | undefined): Ga
} }
/** /**
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the * Run one turn end-to-end against the (injectable) LLM: build prompts, parse/
* model ONCE over the whole pending batch, parse/validate, retry once with a * validate, retry once with a nudge if the result is empty/invalid, and — if
* nudge if the result is empty/invalid, and — if it is still invalid — emit a * it is still invalid — emit a generic "unclear" event and advance. The
* generic "unclear" event and advance. The committed state has `pending` cleared. * committed state has `pending` cleared. Never throws on bad model output;
* Never throws on bad model output; only the underlying transport/network can throw. * only the underlying transport/network can throw.
*
* When `getNeighbours` is provided AND the LLM client implements
* `chatWithTools` (v0.21, #16 P2), the turn runs the bounded function-
* calling loop with the getNeighbours tool (the model may verify land
* borders before committing a territory transfer) and the user prompt gains
* the player's `frontier` field. Otherwise it falls back to a plain chat()
* call — same as before the tool existed.
*/ */
export async function runTurn(opts: { export async function runTurn(opts: {
llm: LlmClient; llm: LlmClient;
preset: Preset; preset: Preset;
state: GameState; state: GameState;
items: RoundItem[]; items: RoundItem[];
/** Region reference table (key -> "name (parent)") to include in the user prompt. */
regionReference?: Record<string, string>;
/** Set of valid region keys; territory changes with other keys are ignored. */
validRegionKeys?: ReadonlySet<string>;
/**
* Region adjacency resolver (keys -> bordering keys, from the precomputed
* manifest). Its presence (together with the client's chatWithTools)
* switches the turn onto the getNeighbours tool path.
*/
getNeighbours?: (keys: string[]) => string[];
}): Promise<{ state: GameState; result: TurnResult }> { }): Promise<{ state: GameState; result: TurnResult }> {
const { llm, preset, state, items } = opts; const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours } = opts;
const system = buildSystemPrompt(preset); const chatWithTools = llm.chatWithTools;
const user = buildUserPrompt(state, items); const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
const system = buildSystemPrompt(preset, useTools);
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
const user = buildUserPrompt(state, items, regionReference, frontier);
const nudge = const nudge =
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.'; 'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
let result = parseTurnResult(await llm.chat([ /**
* One model call with the standard turn sampling: the bounded
* function-calling loop when available, else a plain chat(). The nudge
* re-send below always starts a FRESH history (no tool context carried
* over).
*/
const call = async (history: WireMessage[]): Promise<string> => {
if (useTools) {
return await chatWithTools(
history,
[GET_NEIGHBOURS_TOOL],
makeNeighboursExecutor(getNeighbours, validRegionKeys),
{ temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS },
);
}
return llm.chat(history as ChatMessage[], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS });
};
let result = parseTurnResult(await call([
{ role: 'system', content: system }, { role: 'system', content: system },
{ role: 'user', content: user }, { role: 'user', content: user },
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS })); ]));
if (result === null || !isMeaningful(result)) { if (result === null || !isMeaningful(result)) {
result = parseTurnResult(await llm.chat([ result = parseTurnResult(await call([
{ role: 'system', content: system }, { role: 'system', content: system },
{ role: 'user', content: user }, { role: 'user', content: user },
{ role: 'assistant', content: '' }, { role: 'assistant', content: '' },
{ role: 'user', content: nudge }, { role: 'user', content: nudge },
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS })); ]));
} }
if (result === null || !isMeaningful(result)) { if (result === null || !isMeaningful(result)) {
@@ -409,7 +604,7 @@ export async function runTurn(opts: {
}; };
} }
return { state: applyTurnResult(state, result), result }; return { state: applyTurnResult(state, result, validRegionKeys), result };
} }
// --- Advisor (brainstorm) ----------------------------------------------------- // --- Advisor (brainstorm) -----------------------------------------------------
+15 -15
View File
@@ -19,8 +19,8 @@ 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, queueRoundItem, removeRoundItem, runTurn } from './engine';
import { expandTerritory, loadRegions } from './regions';
import { buildRegionMap } from './map'; import { buildRegionMap } from './map';
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, 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 +42,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 +50,9 @@ const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
// the e2e config, so honor it as a fallback to keep all three in agreement. // 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 +120,15 @@ 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.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 });
}); });
@@ -241,6 +235,12 @@ app.post('/api/games/:id/jump', async (req, res) => {
preset, preset,
state: game, state: game,
items: game.pending, items: game.pending,
regionReference: referenceForKeys(allTerritoryKeys(game)),
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,
}); });
db.save(state); db.save(state);
broadcast(id, 'turn', { state, result }); broadcast(id, 'turn', { state, result });
+179
View File
@@ -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
View File
@@ -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 };
} }
+47 -45
View File
@@ -3,8 +3,10 @@ import { buildRegionMap } from './map';
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol'; import { mapFeatureOwner, nationColor, regionOwner, 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 +20,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,7 +28,7 @@ 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: {},
}, },
}, },
@@ -35,29 +37,28 @@ const state: GameState = {
version: 1, version: 1,
}; };
// 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();
}); });
}); });
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 +66,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 +78,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 +108,30 @@ describe('#6 — region owner must be resolved from properties.regionId, never t
}); });
}); });
it('the repacked numeric feature.id does NOT resolve — the original bug', () => { it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
// 'DEU-03' is a sub-region id; the RegionMap is keyed by region key.
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
});
it('the repacked numeric feature.id does NOT resolve — the bug', () => {
// String(0) === '0' is not a valid key, so the pre-fix lookup failed and // 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();
}); });
}); });
+7 -9
View File
@@ -1,10 +1,8 @@
// 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).
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 +10,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;
+167 -151
View File
@@ -1,176 +1,192 @@
/** 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, expandCountriesToKeys,
expandTerritory, hasAdjacency,
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(() => {
id: 'g', expect(loadRegions(root)).toBe(true);
presetId: 'p', });
presetTitle: 'P',
playerNationId: 'uk', it('loads the full manifest: 1,796 unique valid region keys', () => {
turn: 1, expect(hasRegionData()).toBe(true);
date: '1939-09-01', expect(validKeys().size).toBe(1796);
nations: { });
uk: {
id: 'uk', it('expands an ISO_A3 country to the region keys of all its sub-regions', () => {
name: 'UK', const fra = expandCountriesToKeys(['FRA']);
type: 'major', // FRA has 42 sub-regions in the committed dataset.
stats: { military: 80, economy: 70, stability: 60, population: 50 }, expect(fra).toHaveLength(42);
territory, // every expanded key is a real region key
relations: {}, for (const k of fra) expect(validKeys().has(k)).toBe(true);
// and the reference table labels each one with its parent country
const ref = referenceForKeys(fra);
expect(Object.keys(ref)).toHaveLength(42);
for (const [k, label] of Object.entries(ref)) {
expect(label.endsWith('(FRA)')).toBe(true);
expect(regionName(k)).toBeTruthy();
}
// sample known FRA keys, and their exact reference strings
expect(fra).toContain('ain-lqtt');
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
expect(ref['aisne-gstu']).toBe('Aisne (FRA)');
});
it('referenceForKeys labels only known keys and omits unknown ones', () => {
const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']);
expect(ref['ain-lqtt']).toBe('Ain (FRA)');
expect(ref['not-a-real-key-xyz']).toBeUndefined();
});
it('allTerritoryKeys unions every nation\'s territory keys (no overlap)', () => {
const state: GameState = {
id: 'g',
presetId: 'p',
presetTitle: 'P',
playerNationId: 'uk',
turn: 1,
date: '1939-09-01',
nations: {
uk: {
id: 'uk',
name: 'UK',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: expandCountriesToKeys(['GBR']),
relations: {},
},
fr: {
id: 'fr',
name: 'FR',
type: 'major',
stats: { military: 80, economy: 70, stability: 60, population: 50 },
territory: expandCountriesToKeys(['FRA']),
relations: {},
},
}, },
}, events: [],
events: [], pending: [],
pending: [], version: 1,
version: 1, };
}; // 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', () => { describe('graceful degradation with no region data', () => {
expect(expandTerritory(['FRA', 'FRA'])).toEqual(expandTerritory(['FRA'])); beforeEach(() => resetRegions());
// two countries: the union, in first-seen order (the ids are disjoint)
expect(expandTerritory(['FRA', 'DEU'])).toEqual([ it('expands to the raw input, omits references, and reports no data', () => {
...expandTerritory(['FRA']), expect(hasRegionData()).toBe(false);
...expandTerritory(['DEU']), // 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([]);
});
}); });
it('expandTerritory of an empty or unknown list yields no regions', () => { afterAll(() => resetRegions());
expect(expandTerritory([])).toEqual([]);
expect(expandTerritory(['XXX'])).toEqual([]); // no country with that code
});
it('regionNamesFor maps every known id to "name (parent)"', () => {
const ids = expandTerritory(['DEU']);
const names = regionNamesFor(ids);
expect(Object.keys(names).sort()).toEqual([...ids].sort());
for (const id of ids) expect(names[id]).toMatch(/\(DEU\)$/);
});
it('regionNamesFor skips ids that are not in the loaded index', () => {
expect(regionNamesFor(['NO-SUCH-01'])).toEqual({});
});
it('regionLabel is "id — name (parent)" for known ids and the bare id for unknown', () => {
const [id] = expandTerritory(['FRA']);
expect(regionLabel(id)).toMatch(new RegExp(`^${id} — .+ \\(FRA\\)$`));
expect(regionLabel('NO-SUCH-01')).toBe('NO-SUCH-01');
});
it('migrateTerritoryToRegions rewrites ISO_A3 entries to region ids, keeps existing region ids, and is idempotent', () => {
const [deId] = expandTerritory(['DEU']); // a real region id of another country
const state = makeState(['FRA', deId]); // mixed: legacy ISO_A3 + already migrated
const originalArray = state.nations.uk.territory;
// it mutates and returns the (fresh) state it is given — callers pass a
// just-parsed object (db.load), so the in-place mutation is safe
const out = migrateTerritoryToRegions(state);
expect(out).toBe(state);
// the original array object is never mutated in place — a new one is
// installed on the nation, and the original stays as it was
expect(out.nations.uk.territory).not.toBe(originalArray);
expect(originalArray).toEqual(['FRA', deId]);
// the ISO_A3 entry is gone, replaced by its sub-region ids…
expect(out.nations.uk.territory).not.toContain('FRA');
for (const id of expandTerritory(['FRA'])) expect(out.nations.uk.territory).toContain(id);
// …and the existing region id survives, exactly once
expect(out.nations.uk.territory.filter((t) => t === deId)).toEqual([deId]);
// idempotent: a second migration changes nothing
expect(migrateTerritoryToRegions(out).nations.uk.territory).toEqual(out.nations.uk.territory);
});
}); });
describe('server/regions (fallback: geo not loaded)', () => { describe('loadRegions resolves the manifest per environment (dev public/ vs prod dist/)', () => {
beforeAll(() => { const realRoot = root;
_resetRegionsForTests(); // 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
afterAll(() => { // broke v0.19.0 in prod (the loader only looked in public/).
// restore the real geo for anything in this file that runs later const base = mkdtempSync(path.join(tmpdir(), 'history-regions-'));
_resetRegionsForTests(); const prodRoot = path.join(base, 'prod');
loadRegions(root);
});
it('hasRegionData is false after a reset', () => { beforeEach(() => {
expect(hasRegionData()).toBe(false); mkdirSync(path.join(prodRoot, 'dist'), { recursive: true });
}); copyFileSync(
path.join(realRoot, 'public', 'region-keys.json'),
it('expandTerritory returns its input unchanged (whole-country fallback)', () => { path.join(prodRoot, 'dist', 'region-keys.json'),
expect(expandTerritory(['FRA'])).toEqual(['FRA']);
expect(expandTerritory([])).toEqual([]);
});
it('regionNamesFor is empty and regionLabel falls back to the bare id', () => {
expect(regionNamesFor(['FRA'])).toEqual({});
expect(regionLabel('FRA')).toBe('FRA');
});
it('migrateTerritoryToRegions is a no-op (returns the state untouched)', () => {
const state = makeState(['FRA']);
const out = migrateTerritoryToRegions(state);
expect(out).toBe(state);
expect(out.nations.uk.territory).toEqual(['FRA']);
});
it('_setRegionsForTests loads a fixture and drives the pure helpers', () => {
_setRegionsForTests(
{
'GB-01': { name: 'Greater London', parent: 'GBR' },
'GB-02': { name: 'Scotland', parent: 'GBR' },
},
{ GBR: ['GB-01', 'GB-02'] },
); );
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(1796);
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(1796);
});
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([]);
});
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);
}); });
}); });
+156 -136
View File
@@ -1,102 +1,144 @@
// 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) — 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)
// //
// 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[]>;
} }
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, and adjacency. Idempotent.
* call is a no-op. Tries `<root>/public/regions.geo.json` first (dev), then * The files are served to the client from `public/` in dev (Vite serves it
* `<root>/dist/regions.geo.json` (prod, where Vite copies `public/` → `dist/`). * directly) and from `dist/` in prod (Vite copies `public/*` → `dist/` at build
* If no candidate file is found, or it fails to parse, the maps stay empty and * and the Docker image keeps only `dist/`), so probe `public/` then `dist/`
* `hasRegionData()` stays false (graceful fallback to ISO_A3) — never throws. * (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). 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 base = [
]; path.join(root, 'public'), // dev: Vite serves public/ at /
const file = candidates.find((c) => existsSync(c)); path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
if (!file) { root, // last resort
console.warn( ].find((d) => existsSync(path.join(d, keyFile)) && existsSync(path.join(d, geoFile)));
'[history] regions.geo.json not found (tried: ' + candidates.join(', ') + if (!base) {
') — falling back to whole-country (ISO_A3) territory', data = null;
); return false;
return;
} }
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 = {};
}
}
data = { idToKey, keyToInfo, keysByCountry, validKeys, adjacency };
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 +146,62 @@ 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);
}
}
n.territory = out;
} }
return state; return out;
} }
// --- 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; * The union of the region keys that share a land border with any of the given
loaded = Object.keys(index).length > 0; * 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();
} }
+34 -12
View File
@@ -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,17 @@ 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[];
} }
function truncate(s: string, n: number): string { function truncate(s: string, n: number): string {
@@ -31,12 +37,14 @@ 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.
*/ */
export function deterministicTurn(p: StubPayload): TurnResult { export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state; const state = p.state;
@@ -47,9 +55,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 +107,7 @@ 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,
}; };
} }
+26 -30
View File
@@ -23,13 +23,7 @@ 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[];
@@ -54,31 +48,32 @@ export interface TurnResult {
territoryChanges: TerritoryChange[]; territoryChanges: TerritoryChange[];
} }
/** Per-region map payload: **sub-region id** -> owning nation + its stable color. */ /** Per-region map payload: region key -> owning nation + its stable color. */
export interface RegionOwner { 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 +82,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). */
+4 -9
View File
@@ -26,15 +26,10 @@ export const RELATION_STATUSES: RelationStatus[] = [
]; ];
/** /**
* A single nation in the world. `territory` holds **sub-region ids** (e.g. * 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;
+4 -5
View File
@@ -591,12 +591,11 @@ function GameScreen({
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);
}, },
+33 -9
View File
@@ -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) => {
<span const label = keyNames[key] ?? key;
key={code} return (
className="rounded border px-1.5 py-0.5 text-xs tabular-nums" <span
style={{ borderColor: `${color}66`, color, background: `${color}12` }} key={key}
> className="rounded border px-1.5 py-0.5 text-xs"
{code} style={{ borderColor: `${color}66`, color, background: `${color}12` }}
</span> title={key}
))} data-region-key={key}
>
{label}
</span>
);
})}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground">No territory.</p> <p className="text-sm text-muted-foreground">No territory.</p>
+126 -102
View File
@@ -11,13 +11,12 @@ maplibregl.setWorkerUrl(workerUrl);
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol'; import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
import type { Nation } from '@shared/types'; import 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;
@@ -103,42 +102,44 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
/** /**
* Stamp the current ownership + colors onto a clone of the sub-national base geo. * 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]`);
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned sub-region);
* - `stroke` – a per-border alpha (owned brighter than unowned); * - `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.
*/ */
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection { function applyRegions(
geo: FeatureCollection,
regions: RegionMap,
playerNationId: string,
idKey: Record<string, string>,
): FeatureCollection {
const out: FeatureCollection = JSON.parse(JSON.stringify(geo)); 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; f.properties.fill = r.color;
f.properties.owner = r.owner; f.properties.owner = r.owner;
@@ -157,15 +158,22 @@ function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId
} }
/** /**
* Build a FeatureCollection of just the sub-regions whose **own sub-region id** * 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)) };
} }
@@ -179,11 +187,11 @@ 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 "No owner" card is shown instead. The static
* (`properties.parent`) is deliberately NOT printed here — it is a geographic * parent-country code (`properties.parent`) is deliberately NOT printed here —
* fact that never updates when ownership is transferred, so only the live * it never updates when ownership is transferred, so only the live owner chip
* owner chip (resolved from the RegionMap by sub-region id) is shown. * (resolved from the RegionMap) is shown.
*/ */
function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string { function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string {
if (!region || !nation) { if (!region || !nation) {
@@ -207,39 +215,36 @@ interface Props {
className?: string; className?: 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;
} }
/** /**
* 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.
* *
* 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.
@@ -255,6 +260,9 @@ export default function WorldMap({
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);
@@ -310,17 +318,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,10 +374,10 @@ 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;
@@ -392,22 +411,19 @@ 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;
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);
}; };
@@ -435,34 +451,40 @@ export default function WorldMap({
} }
}; };
// Stamp ownership + colors onto the regions source (no-op until geo loads). // Stamp ownership + colors onto the regions source (no-op until both the
// base geo and the region-key manifest are loaded).
const paintRegions = () => { const 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));
}; };
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 (cancelled) return;
paintRegions();
} }
if (!idKeyRef.current) {
try {
await loadIdKey();
} catch (err) {
console.error('failed to load region keys', err);
}
}
if (cancelled) return;
paintRegions();
if (!map.getLayer(FILL_LAYER)) { 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',
@@ -542,28 +564,30 @@ 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.
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;
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));
} }
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);
} }
if (changed.size > 0) flashApiRef.current?.([...changed]); if (changed.size > 0) flashApiRef.current?.([...changed]);
} }
+46
View File
@@ -0,0 +1,46 @@
// Client-side region-key data access. Fetches the two static JSON files served
// from public/ and builds the lookups the region-level UI needs:
// - idToKey(): feature id -> region key (to stamp each map feature + resolve owner)
// - keyToName(): region key -> human name (to show region names in the dossier)
// Each is fetched once and cached (Promise singleton) so WorldMap and NationPanel
// share a single fetch. Same-origin relative URLs (proxy/Traefik safe) — never a
// hardcoded origin.
type GeoLike = {
features?: Array<{ id?: string | number; properties?: { name?: string } }>;
};
let idKeyPromise: Promise<Record<string, string>> | null = null;
let keyNamePromise: Promise<Record<string, string>> | null = null;
async function fetchJson<T>(url: string): Promise<T> {
const r = await fetch(url);
if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
return (await r.json()) as T;
}
/** feature id (from regions.geo.json) -> region key (from region-keys.json). */
export function idToKey(): Promise<Record<string, string>> {
idKeyPromise ??= fetchJson<Record<string, string>>('/region-keys.json');
return idKeyPromise;
}
/**
* region key -> human region name. Built by joining region-keys.json (id -> key)
* with regions.geo.json (id -> properties.name). A key with no known name is
* simply absent (callers fall back to the raw key).
*/
export function keyToName(): Promise<Record<string, string>> {
keyNamePromise ??= Promise.all([idToKey(), fetchJson<GeoLike>('/regions.geo.json')]).then(
([keys, geo]) => {
const out: Record<string, string> = {};
for (const f of geo.features ?? []) {
const id = f.id == null ? '' : String(f.id);
const key = keys[id];
const name = f.properties?.name;
if (key && typeof name === 'string') out[key] = name;
}
return out;
},
);
return keyNamePromise;
}