Compare commits

...
Author SHA1 Message Date
kamilkrzywda edaa8f55d1 Merge pull request 'No man's land: region release + re-claim + distinct grey map rendering (v0.22.0) — part 3 of #16' (#37) from feat/16-p3-no-mans-land into master
CI / build + unit + e2e (push) Successful in 2m48s
2026-10-14 14:09:22 +02:00
kamilkrzywda 6c52e10898 Bump to v0.22.0 (no man's land, #16 P3) + AGENTS.md/CHANGELOG
CI / build + unit + e2e (pull_request) Successful in 1m33s
2026-10-14 12:04:03 +00:00
kamilkrzywda 764f24fc55 No man's land: TurnResult.unowned + release/re-claim in applyTurnResult + distinct grey map rendering (#16 P3)
- shared/protocol.ts: TurnResult.unowned (keys released this turn) + the map
  presentation contract (regionFillStyle / unownedTooltipLabel, hue-less grey)
- shared/types.ts: required GameState.unowned (cumulative mid-game releases;
  [] at init, distinct from preset-unassigned regions)
- server/engine.ts: applyTurnResult applies releases first (sole-owner only,
  never fabricating no man's land from unassigned keys), then claims against
  the post-release snapshot (gain on an ownerless key = re-claim); lone
  release is isMeaningful; prompts + user payload carry the unowned field
- server/stub.ts: unchanged behavior (never releases; e2e stays stable)
- server/db.ts: legacy rows load with unowned defaulted to []
- server/index.ts: create-game sets []; jump reference table includes
  released keys so the model can name them for re-claim
- WorldMap/App: unowned prop -> visible grey + 'No man's land' tooltip
- unit tests: 105 -> 127 (release/re-claim suites, presentation contract,
  legacy-row normalization)
2026-10-14 12:04:03 +00:00
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
22 changed files with 14499 additions and 130 deletions
+90 -34
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; 127 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 (incl. `unowned`, v0.22 #16 P3) + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers (incl. the no-man's-land map-presentation contract)
├── 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 + no-man's-land releases, v0.22 #16 P3) / 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/`; 127 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,60 @@ history/
(model down) surfaces as an HTTP 502 to the client (shown as a toast). (model down) surfaces as an HTTP 502 to the client (shown as a toast).
- **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the - **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the
server's `RegionMap` and any client coloring so the choropleth always agrees. server's `RegionMap` and any client coloring so the choropleth always agrees.
- **Map click → dossier target (owner resolved from the RegionMap, via the parent country).** - **Region-level territory (v0.19/v0.21, #16 P1+P2).** Since v0.19 ownership lives at sub-region
granularity: `Nation.territory` holds **region keys** (`<name-slug>-<4char>`, e.g. `moselle-rygy`;
the manifest is `public/region-keys.json`), `server/regions.ts` expands each preset's country list
to all its sub-regions' keys at game creation, and `buildRegionMap` keys the choropleth `RegionMap`
by region key. Since v0.21 transfers are **frontier-biased**: `applyTurnResult` is snapshot-first
and enforces **sole ownership** (a key transfers only when exactly one nation claims it to gain,
it has exactly one current owner, and the owner is not the gainer — a double claim is rejected and
a lone `lose` is a no-op, so a key can never be orphaned or duplicated), and the LLM can call the
`getNeighbours` tool (`GET_NEIGHBOURS_TOOL` + `makeNeighboursExecutor` in `engine.ts`, data from
`public/region-adjacency.json` via `neighboursOf`) to verify land borders before proposing a
transfer; the user payload carries the player's `frontier` (the sorted other-nation keys bordering
the player's territory, via `playerFrontier`). The tool path degrades gracefully: without the
adjacency manifest (or with a client lacking `chatWithTools`), a turn runs the legacy plain
`chat()` path and the tool/frontier simply switch off. Since v0.22 (#16 P3) a key may also end
a turn **unowned** — "no man's land": `TurnResult.unowned` lists keys released this turn;
`applyTurnResult` applies releases FIRST (a key leaves its sole owner — only a key with exactly
one current owner can be released; a never-assigned or corrupt key is a no-op), then claims
against the post-release snapshot, so a `gain` on an ownerless key is a **re-claim** (granted to
the sole gainer and removed from the cumulative `GameState.unowned` list). The user payload
carries the current `GameState.unowned` as `unowned`, and the jump route's `regions` reference
table includes those keys so the model can name them when re-claiming. The map renders a
released key in the distinct visible grey (`NO_MANS_LAND_FILL` in `shared/protocol.ts`, with a
"No man's land" tooltip sub-line) while a preset-unassigned key keeps the faint look ("No owner")
— the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` /
`unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted).
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
**parent country's** owning nation (`focusNation(ownerId)` in `GameScreen`, which also records owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
which region was clicked for a small dossier label); the manual "Inspect" selector and a map click clicked for a small dossier label); the manual "Inspect" selector and a map click both set the
both set the same target and stay in sync (a manual pick clears the label). Ownership is resolved same target and stay in sync (a manual pick clears the label). Ownership is resolved from the
from the authoritative `RegionMap` (keyed by the **parent country's** ISO_A3) via the feature's authoritative `RegionMap` (keyed by **region key**) via the feature's stamped
`properties.parent`, using the shared `mapFeatureOwner()` helper (`regionId` still holds the `properties.regionKey`, using the shared `mapFeatureOwner()` helper (`shared/protocol.ts`) —
sub-region's own id, e.g. `FRA-03`). It must **never** be resolved from the feature's top-level `WorldMap` stamps each rendered feature with its key from `region-keys.json` when it builds the
`id` (MapLibre repacks it into a numeric index — the "no owner" bug, #6) nor from the sub-region choropleth source. It must **never** be resolved from the feature's top-level `id` (MapLibre
`id` (not a `RegionMap` key). Sub-regions of a country with no `RegionMap` entry (unowned) leave repacks it into a numeric index — the "no owner" bug, #6) nor from the raw sub-region `id` (not a
the current dossier target unchanged — never a broken dossier. The hover path (a non-closeable `RegionMap` key). A sub-region whose key has no owner leaves the current dossier target
tooltip showing the sub-region name + parent country + owner + key stats) uses the same resolution. unchanged — never a broken dossier. The hover path (a non-closeable tooltip showing the region
**PR 2 of #16:** the map renders `regions.geo.json` (sub-national) but ownership/territory and the name + owner chip + key stats) uses the same resolution. `properties.parent` is static
LLM stay at country level — a nation owns whole countries, so all its sub-regions share the country's geographic-origin data — never use it for ownership or display.
color, and a whole-country transfer flips all its sub-regions at once (PR 3 will make sub-regions
individually ownable).
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per - **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per
queued round item: each `action` item → one military/success event, each `diplomacy` item → one queued round item: each `action` item → one military/success event, each `diplomacy` item → one
diplomatic/partial event, an empty round → one political "The world moves on" event. The player diplomatic/partial event, an empty round → one political "The world moves on" event. The player
nation's `military` is set to `(current + 5)` (clamped) once per committed turn (not per item), nation's `military` is set to `(current + 5)` (clamped) once per committed turn (not per item),
the player is allied with the first other nation (by key order in the `nations` record), and the the player is allied with the first other nation (by key order in the `nations` record), and the
date advances 2 days. Unit + e2e assertions rely on these exact values, so keep the stub date advances 2 days. Since v0.21 the stub also makes **one contiguous conquest per turn**: the
deterministic. player gains `frontier[0]` (the first of the sorted `frontier` list from the prompt payload —
stable) and that region's owner loses it; an empty frontier (no controllable border, e.g. the UK
in `wwii-1939`) transfers nothing. Since v0.22 (#16 P3) the stub **never releases** a region to
no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its
one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is
covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). 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 +254,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 +299,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).
+27
View File
@@ -2,6 +2,33 @@
All notable changes to **History** are listed here, newest first. All notable changes to **History** are listed here, newest first.
## v0.22.0 — 2026-10-14 12:02
- **Part 3 of #16: no man's land — a region can be left unowned mid-game and re-claimed later.** After a war or cataclysm the LLM can leave a region without an owner: it lists the key in the new `TurnResult.unowned` field (and narrates the cause in `events`); the engine removes the key from its sole owner, and the region ends the turn ownerless. A later ordinary `gain` on that key is a **re-claim** — the key is granted to the (sole) gainer and removed from the no-man's-land list. A fresh game always starts fully owned (no man's land can only arise mid-game).
- **Engine (`server/engine.ts`).** `applyTurnResult` applies **releases first**: a key is released only when it has exactly one current owner (0 owners — a preset never assigned it — is a no-op, so no man's land can never be fabricated from an ordinary unowned region; 2+ owners — corruption — is a no-op; already-unowned is a no-op). Claims are then resolved against the post-release ownership snapshot: sole gainer + sole owner ≠ gainer = a transfer (the v0.21 rules, unchanged); sole gainer + no owner = a re-claim; a double claim is still rejected. Invariants after every turn: no key sits in two or more territories, a released key sits in none, and `GameState.unowned` holds exactly the keys released mid-game. A lone release counts as `isMeaningful` (it survives the retry/nudge path instead of being replaced by the generic "unclear" result). The system prompt gains the JSON field, the no-man's-land rule (in both tool-mode and plain-mode), and the hard constraint that `unowned` may contain only region keys a nation currently owns. The user payload always carries the current `GameState.unowned` as `unowned` (even when empty), and the jump route's `regions` reference table now includes the released keys so the model can name them when re-claiming.
- **New required `GameState.unowned` field** (`shared/types.ts`): the cumulative list of keys released mid-game. A no-man's-land region is **distinct from a preset-unassigned region** — the presets assign only ~25–30% of the 1,796 sub-regions (e.g. `wwii-1939` starts with 411 owned / 1,385 unowned), so ~1,300 ownerless regions exist at init; those keep the existing faint look, and only `GameState.unowned` keys get the distinct one. Legacy SQLite rows saved before v0.22 load with the field defaulted to `[]` (`server/db.ts`), and game creation sets it to `[]`.
- **Map presentation (a new contract in `shared/protocol.ts`).** `regionFillStyle(owner, isNoMansLand)` + `unownedTooltipLabel` decide the look: an owned region is unchanged (nation color + `OWNED_STROKE`); a released region gets the distinct visible grey `NO_MANS_LAND_FILL = '#3f3f46'` (hue-less by construction, so it can never collide with a nation's `hsl(h 58% 46%)` color) with a brighter border (`NO_MANS_LAND_STROKE`) and the tooltip sub-line "No man's land"; every other ownerless region keeps the faint `rgba(148,163,184,0.05)` fill and "No owner". The client (`WorldMap`, via a new `unowned` prop from `App.tsx`) is the sole consumer of the distinction — the `RegionMap` simply has no entry for an unowned key (`buildRegionMap` is unchanged). Clicking a no-man's-land region does not change the dossier (there is no owner to focus), and a release/re-claim flashes like any other ownership change.
- **Stub + tests.** The deterministic stub is deliberately **unchanged** — it never releases a region (it always returns `unowned: []` and ignores the payload's `unowned` field), because its one-conquest behavior is what the e2e assertions rely on; the release/re-claim path is therefore covered by **unit tests only** (a new `applyTurnResult (no man's land)` suite: release, re-claim, release+claim of the same key, a double claim on a released key, the four no-op release cases, a legacy state without the field, and a `runTurn` end-to-end test with a narrated release; new `buildUserPrompt`/`buildSystemPrompt` contract tests; a presentation-contract suite in `server/map.test.ts`; a legacy-row normalization test in `server/db.test.ts`). The unit suite grows **105 → 127**.
- **No data-format wipe.** The `GameState` shape only gains a field that legacy rows load with, so existing saves remain compatible: the deploy is a plain `up -d --build` (no `down -v`).
- **Verification.** Typecheck, lint, **127 unit tests**, and the production build all pass. The e2e suite (CI, `LLM_STUB=1`) carries no map-color/no-man's-land assertions, so it stays green with the unchanged stub.
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
- Part of #16 — no man's land (P3); #16 stays open.
## v0.21.0 — 2026-10-14 10:37
- **Part 2 of #16: territory now moves region-by-region along real land borders, and the LLM can verify borders with a live tool.** v0.19 made ownership region-keyed, but the model's `territoryChanges` were still applied wholesale — any key could jump to any nation, effectively moving whole countries. Now:
- **Region-adjacency manifest (Stage 1).** `scripts/build-region-adjacency.mjs` (`npm run build:region-adjacency`) computes which of the 1,796 sub-regions share a land border: per candidate pair, a cheap bbox-proximity prefilter (0.05° gap reject) then a `@turf/boolean-intersects` arbiter. The output `public/region-adjacency.json` is symmetric and deduped — **1,796 entries, 4,470 border pairs (8,940 directed), max degree 19; 1,727 regions have ≥1 neighbour, 69 don't** — and is deterministic/idempotent (sha256 `e9f1d475…`; re-running is byte-identical). Vite copies it into `dist/` for prod; `server/regions.ts` loads it (turf-free — plain `JSON.parse`, no `@turf` import in any `.ts`) and degrades gracefully when it is missing.
- **`getNeighbours` LLM tool (Stage 2).** `server/llm.ts` gains `LlmClient.chatWithTools` — a bounded OpenAI function-calling loop: on a `finish_reason: "tool_calls"` response the client runs the injected executor for every call, appends the assistant tool-call message plus one `tool` result message per call, and re-sends until a final `content` (max **6** tool rounds, then one last *no-tools* request forces the answer). It shares the retry/backoff/`reasoning_effort: "low"` machinery with `chat()`; a tool-call round is a success (never treated as truncation); malformed tool arguments become an `{"error":"invalid tool arguments…"}` tool message, so a bad model reply can never throw. `tools` is included in the request body only when non-empty, so plain `chat()` bodies (and their tests) are unchanged.
- **Engine wiring.** `runTurn` takes an optional `getNeighbours` resolver — the server's jump route passes one only when the manifest loaded (`hasAdjacency()`). With both the resolver and a `chatWithTools`-capable client, the turn runs the tool path: the system prompt gains the contiguity rules (a nation may only GAIN a key that borders its own territory; every key has exactly one owner) and advertises the tool; the user payload gains a `frontier` field — the player's `playerFrontier`, the sorted/deduped set of *other-nation* keys bordering the player's territory (the natural targets of a player-led conquest). Without the manifest, or with a minimal chat-only client, the turn falls back to the legacy plain `chat()` path — everything keeps working, the tool/frontier simply switch off.
- **Sole-ownership enforcement.** `applyTurnResult` now applies territory snapshot-first: before mutating anything it computes each key's current owner, and a key transfers only when (a) exactly one nation claims to gain it (a double claim is rejected — the key stays), (b) it has exactly one current owner (pre-existing double ownership is never touched), and (c) the owner is not the gainer (self-gain is a no-op). A transfer removes the key from all nations first, then grants it to the gainer; lone `lose` entries are no-ops. Every key therefore ends the turn with exactly one owner — it can never be orphaned or duplicated, and a bad model answer degrades to "no transfer", never to a corrupt map.
- **Deterministic stub.** The stub now makes one contiguous conquest per turn: the player gains `frontier[0]` (sorted → stable) and its owner loses it. It answers plain content (no tool calls), so it naturally bypasses the tool loop in a single round; with an empty frontier (no controllable border — e.g. the UK in `wwii-1939`, whose only external neighbour is Ireland) nothing transfers and every existing e2e assertion stays stable.
- **No data-format change — no save wipe.** The `TerritoryChange` wire shape is unchanged and saved games load as-is, so the deploy is a plain rebuild (no `down -v`).
- **Verification.** Typecheck, lint, **105 unit tests** (79 baseline + 26 new: tool-loop protocol — echo, parsed executor args, bounded rounds, malformed args, retry; engine tool-path wiring + fallback; `playerFrontier`; executor degradation; sole-ownership invariants; `neighboursOf`/`hasAdjacency`), the production build, and all **14 e2e** tests pass.
- **Deploy:** manual `docker compose up -d --build` on the host (no `down -v`).
- Part of #16 — P3 (region rename/split/merge) and P4 follow; #16 stays open.
## v0.20.0 — 2026-10-14 09:30
- **Fixed: v0.19.0's region-level ownership did not activate in the production image.** The server-side region loader (`server/regions.ts`) only looked for the manifest + geo in `public/`, but in the Docker image Vite copies `public/*` → `dist/` and no `public/` directory is kept — so `loadRegions` found nothing, `expandCountriesToKeys` degraded to a no-op, and freshly-created games kept raw ISO_A3 codes (`DEU`, `GBR,IRL,IND`, …) instead of expanding to region keys. The choropleth therefore still rendered whole-country ownership and the model's region-key contract was inactive in prod (the client's own `/region-keys.json` fetch still worked, because the client is served from `dist/`). `loadRegions` now probes `public/` (dev) then `dist/` (prod, == `clientDir`) and uses the first directory holding both files, so one build works in both environments. A new test covers the prod-only (`dist/`), dev (`public/`), and empty-root (no data) layouts.
- **Deploy:** `docker compose down -v` (drops the `history-data` volume — pre-v0.19 saves were already dropped by v0.19.0 and are dropped again here) then `up -d --build`.
## v0.19.0 — 2026-10-14 09:14 ## 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`). - **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 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.
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "history", "name": "history",
"version": "0.19.0", "version": "0.22.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).",
@@ -15,6 +15,7 @@
"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-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
+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 ? ', …' : ''}`);
}
+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);
});
});
+12
View File
@@ -23,6 +23,7 @@ function makeState(id: string): GameState {
events: [], events: [],
pending: [], pending: [],
version: 1, version: 1,
unowned: [],
}; };
} }
@@ -73,6 +74,17 @@ describe('openDb', () => {
db.close(); db.close();
}); });
it('(v0.22 #16 P3) a legacy row saved before the unowned field existed loads with it defaulted to []', () => {
const db = openDb(':memory:');
const legacy = makeState('legacy');
delete (legacy as { unowned?: string[] }).unowned; // a pre-v0.22 save (field absent)
db.save(legacy);
const loaded = db.load('legacy');
expect(loaded).not.toBeNull();
expect(loaded!.unowned).toEqual([]);
db.close();
});
it('delete removes a game by id and reports absence otherwise', () => { it('delete removes a game by id and reports absence otherwise', () => {
const db = openDb(':memory:'); const db = openDb(':memory:');
db.save(makeState('a')); db.save(makeState('a'));
+5 -1
View File
@@ -39,7 +39,11 @@ 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 {
return JSON.parse(row.data) as GameState; const state = JSON.parse(row.data) as GameState;
// (v0.22, #16 P3) Rows saved before `GameState.unowned` existed load
// with it defaulted: a legacy game has no no-man's-land regions.
if (state.unowned === undefined) state.unowned = [];
return state;
} catch { } catch {
return null; return null;
} }
+562 -10
View File
@@ -1,14 +1,17 @@
import { 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 { import {
applyTurnResult, applyTurnResult,
buildSystemPrompt, buildSystemPrompt,
buildUserPrompt, buildUserPrompt,
GET_NEIGHBOURS_TOOL,
isMeaningful, isMeaningful,
makeNeighboursExecutor,
parseTurnResult, parseTurnResult,
playerFrontier,
queueRoundItem, queueRoundItem,
removeRoundItem, removeRoundItem,
runTurn, runTurn,
@@ -75,6 +78,7 @@ function baseState(): GameState {
events: [], events: [],
pending: [], pending: [],
version: 1, version: 1,
unowned: [],
}; };
} }
@@ -88,7 +92,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: ['POL'], lose: [] }], // v0.21 (#16 P2): DE gains the UK's GBR — the key has exactly one previous
// owner, so the transfer applies (since v0.22 #16 P3 a key owned by NOBODY
// would be re-claimed instead of ignored; a release lives in "unowned").
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
}); });
describe('runTurn', () => { describe('runTurn', () => {
@@ -109,8 +116,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');
// territory moved to DE // territory moved to DE: it gains GBR (the UK's sole previous owner) and
expect(state.nations.de.territory).toContain('POL'); // the old owner correspondingly loses it (v0.21 #16 P2: one owner per key)
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);
@@ -121,7 +130,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.de.territory).not.toContain('POL'); expect(original.nations.de.territory).not.toContain('GBR');
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 () => {
@@ -329,6 +339,22 @@ describe('buildUserPrompt (batched round)', () => {
const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> }; const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> };
expect(withoutRef.regions).toBeUndefined(); expect(withoutRef.regions).toBeUndefined();
}); });
it('(v0.22 #16 P3) emits the no-man\'s-land list when provided (even when empty) and omits it otherwise', () => {
// runTurn always passes state.unowned (even []), so the field is present...
const withEmpty = JSON.parse(buildUserPrompt(baseState(), [], undefined, undefined, [])) as {
unowned?: string[];
};
expect(withEmpty.unowned).toEqual([]);
// ...and a populated list reaches the model verbatim (it knows what it can re-claim)
const withKeys = JSON.parse(
buildUserPrompt(baseState(), [], undefined, undefined, ['GBR', 'POL']),
) as { unowned?: string[] };
expect(withKeys.unowned).toEqual(['GBR', 'POL']);
// but a caller that does not provide it keeps the legacy payload shape
const omitted = JSON.parse(buildUserPrompt(baseState(), [])) as { unowned?: string[] };
expect(omitted.unowned).toBeUndefined();
});
}); });
describe('queueRoundItem', () => { describe('queueRoundItem', () => {
@@ -451,6 +477,7 @@ describe('applyTurnResult', () => {
stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 99 }],
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [],
}; };
applyTurnResult(state, result); applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot); expect(JSON.stringify(state)).toBe(snapshot);
@@ -465,6 +492,7 @@ describe('applyTurnResult', () => {
stateChanges: [], stateChanges: [],
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [],
}; };
const next = applyTurnResult(state, result); const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]); expect(next.pending).toEqual([]);
@@ -475,6 +503,8 @@ describe('applyTurnResult', () => {
it('with a validKeys set: applies real region keys, ignores hallucinated ones', () => { 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: [],
@@ -483,9 +513,10 @@ describe('applyTurnResult', () => {
// 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz' // '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. // 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: [] }], territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
unowned: [],
}; };
const next = applyTurnResult(state, result, new Set(['wales-bbbb'])); const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
// the valid key was gained (stolen from its owner); the bogus one was ignored // the valid key was gained (taken from its sole previous owner); the bogus one was ignored
expect(next.nations.de.territory).toContain('wales-bbbb'); expect(next.nations.de.territory).toContain('wales-bbbb');
expect(next.nations.uk.territory).not.toContain('wales-bbbb'); expect(next.nations.uk.territory).not.toContain('wales-bbbb');
// the bogus key never entered any nation's territory // the bogus key never entered any nation's territory
@@ -496,23 +527,544 @@ describe('applyTurnResult', () => {
it('without a validKeys set: applies every listed key (legacy/unit path)', () => { 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: [],
// 'wales-bbbb' has a sole previous owner (uk) -> it transfers; 'POL' is
// owned by no one -> a re-claim (v0.22, #16 P3): DE gains it. It was never
// released (not in state.unowned), so it does not enter GameState.unowned.
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }], territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
unowned: [],
}; };
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
expect(next.nations.de.territory).toContain('POL');
expect(next.nations.de.territory).toContain('wales-bbbb'); expect(next.nations.de.territory).toContain('wales-bbbb');
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
expect(next.nations.de.territory).toContain('POL'); // re-claimed, not ignored
expect(next.unowned).toEqual([]); // never released -> not no man's land
});
});
describe('applyTurnResult (sole-ownership invariants, v0.21 #16 P2)', () => {
const noEvents = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
unowned: [],
};
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('duplicate gain entries for the SAME nation are deduped (one claim, one copy)', () => {
const state = baseState();
state.nations.uk.territory = ['GBR', 'wales-bbbb'];
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [
{ nation: 'de', gain: ['wales-bbbb'], lose: [] },
{ 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('applyTurnResult (no man\'s land, v0.22 #16 P3)', () => {
const noEvents = {
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [],
};
it('a release removes the key from its sole owner and marks it in GameState.unowned', () => {
const state = baseState();
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
expect(next.nations.uk.territory).not.toContain('GBR');
expect(next.unowned).toEqual(['GBR']);
// nothing else moved
expect(next.nations.us.territory).toEqual(['USA']);
expect(next.nations.de.territory).toEqual(['DEU']);
});
it('a re-claim (a gain on a currently-unowned key) grants it and removes it from GameState.unowned', () => {
const state = baseState();
state.unowned = ['POL']; // released in an earlier turn
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'de', gain: ['POL'], lose: [] }],
});
expect(next.nations.de.territory).toContain('POL');
expect(next.unowned).toEqual([]); // it left no man's land
});
it('a release and a claim of the same key in one turn: the claim wins (the key is owned)', () => {
const state = baseState();
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
unowned: ['GBR'],
});
expect(next.nations.de.territory).toContain('GBR');
expect(next.nations.uk.territory).not.toContain('GBR');
expect(next.unowned).toEqual([]);
});
it('a double claim on a released key is rejected: the key stays in no man\'s land', () => {
const state = baseState();
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [
{ nation: 'de', gain: ['GBR'], lose: [] },
{ nation: 'us', gain: ['GBR'], lose: [] },
],
unowned: ['GBR'], // released first, then both nations lunge for it
});
expect(next.nations.uk.territory).not.toContain('GBR'); // the release applied
expect(next.nations.de.territory).not.toContain('GBR'); // both claims rejected
expect(next.nations.us.territory).not.toContain('GBR');
expect(next.unowned).toEqual(['GBR']);
});
it('releasing a key no nation owns (a preset-unassigned region) is a no-op — no man\'s land can only arise from a real release', () => {
const state = baseState();
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
expect(next.unowned).toEqual([]);
for (const n of Object.values(next.nations)) expect(n.territory).not.toContain('POL');
});
it('releasing a key with two owners (a corrupt state) is a no-op', () => {
const state = baseState();
state.nations.us.territory = ['USA', 'GBR']; // uk AND us share GBR
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
expect(next.nations.uk.territory).toContain('GBR');
expect(next.nations.us.territory).toContain('GBR');
expect(next.unowned).toEqual([]);
});
it('releasing a key already in no man\'s land is a no-op (it stays listed once)', () => {
const state = baseState();
state.unowned = ['POL'];
const next = applyTurnResult(state, { ...noEvents, unowned: ['POL'] });
expect(next.unowned).toEqual(['POL']);
});
it('with a validKeys set: releasing a hallucinated key is a no-op', () => {
const state = baseState();
const next = applyTurnResult(state, { ...noEvents, unowned: ['not-a-region-xyz'] }, new Set(['wales-bbbb']));
expect(next.unowned).toEqual([]);
});
it('a legacy state without the unowned field accepts a release (the result carries the field)', () => {
const state = baseState();
delete (state as { unowned?: string[] }).unowned; // simulate a pre-v0.22 row
const next = applyTurnResult(state, { ...noEvents, unowned: ['GBR'] });
expect(next.unowned).toEqual(['GBR']);
expect(next.nations.uk.territory).not.toContain('GBR');
});
it('a turn with no releases leaves the existing no man\'s land list unchanged', () => {
const state = baseState();
state.unowned = ['POL'];
const next = applyTurnResult(state, {
...noEvents,
territoryChanges: [{ nation: 'de', gain: ['GBR'], lose: [] }],
});
expect(next.unowned).toEqual(['POL']);
expect(next.nations.de.territory).toContain('GBR');
});
it('validateTurnResult dedupes and filters the unowned list (trimmed non-empty strings only)', () => {
const r = validateTurnResult({
date: '1939-09-03',
events: [],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: [' a ', 'a', '', 42, null, ' b '],
});
expect(r).not.toBeNull();
expect(r!.unowned).toEqual(['a', 'b']);
});
it('a lone release is meaningful (it survives the retry/nudge path)', () => {
expect(
isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: ['GBR'] }),
).toBe(true);
});
it('runTurn applies a release end-to-end: the model narrates it and the key leaves its owner', async () => {
const original = baseState();
original.pending = [{ kind: 'action', text: 'Scorch the earth on the retreat' }];
const releaseJson = JSON.stringify({
date: '1939-09-05',
events: [
{
nation: 'uk',
type: 'military',
title: 'A retreat through the wastelands',
text: 'The abandoned highlands burn and lie empty.',
outcome: 'partial',
},
],
stateChanges: [],
relations: [],
territoryChanges: [],
unowned: ['GBR'],
});
const payloads: string[] = [];
const client: LlmClient = {
chat: async (messages) => {
const user = messages.find((m) => m.role === 'user');
if (user) payloads.push(user.content as string);
return releaseJson;
},
listModels: async () => [],
};
const { state } = await runTurn({ llm: client, preset, state: original, items: original.pending });
// the event landed and the release applied
expect(state.events).toHaveLength(1);
expect(state.events[0].title).toBe('A retreat through the wastelands');
expect(state.nations.uk.territory).not.toContain('GBR');
expect(state.unowned).toEqual(['GBR']);
// on the first turn the model was shown the (empty) no-man's-land list...
const first = JSON.parse(payloads[0]) as { unowned?: string[] };
expect(first.unowned).toEqual([]);
// ...and on the next turn it is shown the released key (so it can re-claim it)
const next = await runTurn({ llm: client, preset, state, items: [] });
const second = JSON.parse(payloads[1]) as { unowned?: string[] };
expect(second.unowned).toEqual(['GBR']);
// the repeated "release" of an already-unowned key is a no-op
expect(next.state.unowned).toEqual(['GBR']);
});
});
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;
},
};
return { client, seen };
}
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);
});
it('(v0.22 #16 P3) both modes contract the no-man\'s-land field (release + re-claim) and constrain it to owned keys', () => {
for (const p of [buildSystemPrompt(preset), buildSystemPrompt(preset, true)]) {
// the JSON shape carries the field in both modes
expect(p).toMatch(/"unowned": \["<region key>"\]/);
// the rule explains release (narrated in events) and re-claim (ordinary gain)
expect(p).toMatch(/no man's land/i);
expect(p).toMatch(/narrate the cause in "events"/);
expect(p).toMatch(/re-claims it with a "gain"/i);
// the hard constraint: only currently-owned keys may be released
expect(p).toMatch(/only region keys that a nation currently owns/);
}
}); });
}); });
describe('isMeaningful', () => { describe('isMeaningful', () => {
it('is true with an event, false when everything is empty', () => { it('is true with an event, false when everything is empty', () => {
expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [] })).toBe(true); expect(isMeaningful({ date: 'x', events: [{ nation: 'uk', type: 'military', title: 't', text: 'b', outcome: 'neutral' }], stateChanges: [], relations: [], territoryChanges: [], unowned: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [] })).toBe(true); expect(isMeaningful({ date: 'x', events: [], stateChanges: [{ nation: 'uk', stat: 'military', op: 'set', value: 5 }], relations: [], territoryChanges: [], unowned: [] })).toBe(true);
expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [] })).toBe(false); expect(isMeaningful({ date: 'x', events: [], stateChanges: [], relations: [], territoryChanges: [], unowned: [] })).toBe(false);
}); });
}); });
+301 -35
View File
@@ -22,7 +22,14 @@ 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';
const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES); const VALID_EVENT_TYPES = new Set<string>(EVENT_TYPES);
@@ -30,13 +37,128 @@ 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:';
// (v0.22, #16 P3) No man's land: a region can be left unowned after a war or
// cataclysm (the model narrates the cause in "events" and lists the key in
// "unowned"); unowned regions can later be re-claimed with an ordinary gain.
// The model sees the current no-man's-land keys in the user payload's
// "unowned" field and in the "regions" reference table.
const noMansLandRule =
'- A region key has at most one owner at all times — never assign a key to two nations. After a war or cataclysm a region may be left unowned ("no man\'s land"): list its key in "unowned" and narrate the cause in "events"; it stays unowned (grey on the map) until a nation re-claims it with a "gain". The "unowned" field of the user message lists the regions currently in no man\'s land.';
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.',
'- 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.',
noMansLandRule,
]
: [
'- Only change borders when war or a decisive political shift justifies it.',
noMansLandRule,
];
return [ return [
`WORLD: ${preset.title} — ${preset.startDate}`, `WORLD: ${preset.title} — ${preset.startDate}`,
preset.worldBackground, preset.worldBackground,
@@ -45,7 +167,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": [',
@@ -53,16 +175,18 @@ 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":["<region key>"],"lose":["<region key>"]}]', ' "territoryChanges": [{"nation":"<nationId>","gain":["<region key>"],"lose":["<region key>"]}],',
' "unowned": ["<region key>"]',
'}', '}',
'', '',
'HARD CONSTRAINTS', 'HARD CONSTRAINTS',
'- Use only nation ids and region keys that exist in the provided state.', '- Use only nation ids and region keys that exist in the provided state.',
'- A region key identifies one sub-region; the "regions" table in the user message maps each key to "<region name> (<parent country ISO_A3>)". Use those keys in territoryChanges.', '- 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 and unowned.',
'- List in "unowned" only region keys that a nation currently owns, and narrate each release in "events".',
'- 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');
} }
@@ -80,6 +204,20 @@ export function buildUserPrompt(
state: GameState, state: GameState,
items: RoundItem[], items: RoundItem[],
regionReference?: Record<string, string>, 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[],
/**
* (v0.22, #16 P3) Region keys that are currently "no man's land" (released
* mid-game — `state.unowned`). Emitted verbatim (even when empty) so the
* model knows which unowned regions exist and can be re-claimed this turn.
* Omitted entirely when undefined.
*/
noMansLand?: string[],
): 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;
@@ -116,6 +254,8 @@ export function buildUserPrompt(
roundInstruction, roundInstruction,
}; };
if (regionReference) payload.regions = regionReference; if (regionReference) payload.regions = regionReference;
if (frontier !== undefined) payload.frontier = frontier;
if (noMansLand !== undefined) payload.unowned = noMansLand;
return JSON.stringify(payload, null, 2); return JSON.stringify(payload, null, 2);
} }
@@ -190,6 +330,7 @@ export function validateTurnResult(input: unknown): TurnResult | null {
stateChanges: [], stateChanges: [],
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [],
}; };
if (Array.isArray(o.events)) { if (Array.isArray(o.events)) {
@@ -265,12 +406,26 @@ export function validateTurnResult(input: unknown): TurnResult | null {
} }
} }
// (v0.22, #16 P3) Keys released to "no man's land": deduped, non-empty
// strings only (applied later by applyTurnResult against validKeys).
if (Array.isArray(o.unowned)) {
for (const k of o.unowned) {
const s = typeof k === 'string' ? k.trim() : '';
if (s && !result.unowned.includes(s)) result.unowned.push(s);
}
}
return result; return result;
} }
/** A turn is "meaningful" if it produced at least an event or a stat change. */ /**
* A turn is "meaningful" if it produced at least an event, a stat change, or
* a no-man's-land release (v0.22 #16 P3: a release is a real world change even
* when the model under-narrates it, so it must survive the retry/nudge path
* instead of being replaced by the generic "unclear" result).
*/
export function isMeaningful(result: TurnResult): boolean { export function isMeaningful(result: TurnResult): boolean {
return result.events.length > 0 || result.stateChanges.length > 0; return result.events.length > 0 || result.stateChanges.length > 0 || result.unowned.length > 0;
} }
// --- Applying the delta ------------------------------------------------------- // --- Applying the delta -------------------------------------------------------
@@ -287,7 +442,8 @@ 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 (including no-man's-land releases, v0.22 #16 P3).
*/ */
export function applyTurnResult(state: GameState, result: TurnResult, validKeys?: ReadonlySet<string>): 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));
@@ -312,24 +468,97 @@ export function applyTurnResult(state: GameState, result: TurnResult, validKeys?
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: at most one owner per key;
const n = next.nations[t.nation]; // v0.22, #16 P3: a key may also be released to "no man's land") ---
if (!n) continue; //
const valid = (k: string) => !validKeys || validKeys.has(k); // Two kinds of change, applied in this order:
for (const key of t.gain) { //
if (!valid(key)) continue; // ignore a key that is not a real region // (1) RELEASES (`result.unowned`, v0.22 #16 P3): each key that a nation
for (const other of Object.values(next.nations)) { // CURRENTLY owns (exactly one owner) is removed from that owner, left
const i = other.territory.indexOf(key); // ownerless, and added to `GameState.unowned` — the client renders it
if (i !== -1) other.territory.splice(i, 1); // as the distinct no-man's-land grey. A key with zero owners (a preset
} // never assigned it) is never released: doing so would fabricate a
if (!n.territory.includes(key)) n.territory.push(key); // visible no-man's-land out of an ordinary unowned region. A key with
} // two or more owners (pre-existing corruption) is never touched, and
for (const key of t.lose) { // a key already in `unowned` is a no-op (it is already unowned).
//
// (2) CLAIMS (`gain` entries), resolved against the ownership AFTER the
// releases (snapshot-first):
// - a key with exactly one owner that is not the gainer → a TRANSFER:
// removed from the old owner, granted to the gainer (v0.21 rules);
// - a key with NO owner → a RE-CLAIM: granted to the (sole) gainer and
// removed from `GameState.unowned` (it leaves no man's land);
// - a double claim (two or more distinct gainers) is rejected — the
// key stays as it is; a self-gain is a no-op; a pre-existing double
// ownership is never touched.
//
// Invariants after the turn: no key sits in two or more territories; a
// released key sits in none; `GameState.unowned` holds exactly the
// zero-owner keys that were released mid-game (not the preset-unassigned
// ones). "lose" entries remain deliberate no-ops: releasing is the
// `unowned` list's job, and a lone loss without a release would just
// orphan a key silently.
const valid = (k: string) => !validKeys || validKeys.has(k);
const unowned = new Set(state.unowned ?? []);
// (1) Releases: establish the new no-man's-land set before any claim.
for (const key of new Set(result.unowned)) {
if (!valid(key) || unowned.has(key)) continue;
const owners = Object.values(next.nations)
.filter((n) => n.territory.includes(key))
.map((n) => n.id);
if (owners.length !== 1) continue; // 0 owners (never assigned) or 2+ (corrupt): no-op
unowned.add(key);
next.nations[owners[0]].territory = next.nations[owners[0]].territory.filter((k) => k !== key);
}
// (2) Claims against the post-release ownership snapshot.
const ownerOf = new Map<string, string>(); // key -> sole owner id, or '' (2+ owners; 0 = absent)
for (const n of Object.values(next.nations)) {
for (const key of n.territory) {
if (!valid(key)) continue; 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); // undefined: no owner; '': 2+ owners (corrupt)
if (owner === '') continue; // pre-existing double ownership: never touched
if (owner !== undefined && owner === gainer) continue; // self-gain: no-op
for (const n of Object.values(next.nations)) {
const i = n.territory.indexOf(key); const i = n.territory.indexOf(key);
if (i !== -1) n.territory.splice(i, 1); if (i !== -1) n.territory.splice(i, 1);
} }
next.nations[gainer].territory.push(key);
unowned.delete(key); // re-claimed: the key leaves no man's land
} }
next.unowned = [...unowned];
return next; return next;
} }
@@ -365,11 +594,18 @@ 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;
@@ -380,25 +616,54 @@ export async function runTurn(opts: {
regionReference?: Record<string, string>; regionReference?: Record<string, string>;
/** Set of valid region keys; territory changes with other keys are ignored. */ /** Set of valid region keys; territory changes with other keys are ignored. */
validRegionKeys?: ReadonlySet<string>; 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, regionReference, validRegionKeys } = opts; const { llm, preset, state, items, regionReference, validRegionKeys, getNeighbours } = opts;
const system = buildSystemPrompt(preset); const chatWithTools = llm.chatWithTools;
const user = buildUserPrompt(state, items, regionReference); const useTools = getNeighbours !== undefined && chatWithTools !== undefined;
const system = buildSystemPrompt(preset, useTools);
const frontier = getNeighbours ? playerFrontier(state, getNeighbours) : undefined;
// (v0.22, #16 P3) the model always sees the current no-man's-land keys (empty
// list on a fresh game) so it knows which unowned regions can be re-claimed.
const user = buildUserPrompt(state, items, regionReference, frontier, state.unowned ?? []);
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)) {
@@ -408,6 +673,7 @@ export async function runTurn(opts: {
stateChanges: [], stateChanges: [],
relations: [], relations: [],
territoryChanges: [], territoryChanges: [],
unowned: [],
}; };
} }
+15 -2
View File
@@ -20,7 +20,7 @@ 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 { buildRegionMap } from './map'; import { buildRegionMap } from './map';
import { allTerritoryKeys, expandCountriesToKeys, loadRegions, referenceForKeys, validKeys } from './regions'; 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';
@@ -124,6 +124,10 @@ app.post('/api/games', (req: Request, res) => {
events: [], events: [],
pending: [], pending: [],
version: 1, // the first saved revision; every later mutation bumps it version: 1, // the first saved revision; every later mutation bumps it
// (v0.22, #16 P3) No man's land can only arise mid-game — a fresh game
// starts with nothing released; this list grows as the LLM releases
// regions and shrinks as they get re-claimed.
unowned: [],
}; };
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys // 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 // of all its sub-regions, so ownership (and the choropleth) is at region
@@ -235,8 +239,17 @@ 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)), // (v0.22, #16 P3) the region reference table must also cover released
// (no-man's-land) keys — they belong to no nation's territory, but the
// model needs their names to re-claim them.
regionReference: referenceForKeys(
new Set([...allTerritoryKeys(game), ...(game.unowned ?? [])]),
),
validRegionKeys: validKeys(), 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 };
} }
+69 -1
View File
@@ -1,6 +1,18 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildRegionMap } from './map'; import { buildRegionMap } from './map';
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol'; import {
mapFeatureOwner,
nationColor,
NO_MANS_LAND_FILL,
NO_MANS_LAND_STROKE,
OWNED_STROKE,
regionFillStyle,
regionOwner,
UNOWNED_FILL,
UNOWNED_STROKE,
unownedTooltipLabel,
type RegionMap,
} from '../shared/protocol';
import type { GameState } from '../shared/types'; import type { GameState } from '../shared/types';
// Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed // Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed
@@ -35,6 +47,7 @@ const state: GameState = {
events: [], events: [],
pending: [], pending: [],
version: 1, version: 1,
unowned: [],
}; };
// The RegionMap built from the fixture: the authoritative region key → owner source. // The RegionMap built from the fixture: the authoritative region key → owner source.
@@ -49,6 +62,16 @@ describe('buildRegionMap', () => {
// a region nobody owns is simply absent // a region nobody owns is simply absent
expect(m['normandie-dddd']).toBeUndefined(); expect(m['normandie-dddd']).toBeUndefined();
}); });
it('(v0.22 #16 P3) a released (no-man\'s-land) key — in GameState.unowned, owned by no nation — is absent: absence IS unowned', () => {
const s: GameState = JSON.parse(JSON.stringify(state));
// simulate a mid-game release: the key left every territory and entered unowned
s.nations.uk.territory = s.nations.uk.territory.filter((k) => k !== 'albania-aaaa');
s.unowned = ['albania-aaaa'];
const m = buildRegionMap(s);
expect(m['albania-aaaa']).toBeUndefined(); // the map cannot tell which kind of unowned
expect(s.unowned).toEqual(['albania-aaaa']); // ...the client's GameState.unowned decides that
});
}); });
describe('regionOwner', () => { describe('regionOwner', () => {
@@ -143,3 +166,48 @@ describe('nationColor', () => {
expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/); expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/);
}); });
}); });
// (v0.22, #16 P3) The no-man's-land presentation contract lives in
// shared/protocol (shared/*.test.ts is not in the vitest glob, so these
// tests live here): the distinct visible grey, the faint preset-unassigned
// look, the per-kind borders, and the tooltip sub-line.
describe('no man\'s land presentation contract (v0.22 #16 P3)', () => {
it('regionFillStyle: an owned region uses its nation color + the owned border', () => {
expect(regionFillStyle(regions['albania-aaaa'], false)).toEqual({
fill: nationColor('uk'),
stroke: OWNED_STROKE,
});
expect(regionFillStyle(regions['brandenburg-cccc'], true)).toEqual({
fill: nationColor('de'),
stroke: OWNED_STROKE,
}); // the no-man's-land flag never affects an owned region
});
it('regionFillStyle: an unowned region that IS no man\'s land gets the distinct visible grey', () => {
expect(regionFillStyle(undefined, true)).toEqual({
fill: NO_MANS_LAND_FILL,
stroke: NO_MANS_LAND_STROKE,
});
});
it('regionFillStyle: an unowned region that was never released keeps the faint look', () => {
expect(regionFillStyle(undefined, false)).toEqual({
fill: UNOWNED_FILL,
stroke: UNOWNED_STROKE,
});
});
it('the no-man\'s-land grey can never collide with a nation color (hue-less vs HSL)', () => {
expect(NO_MANS_LAND_FILL).not.toMatch(/^hsl\(/);
expect(NO_MANS_LAND_FILL).not.toBe(nationColor('uk'));
expect(NO_MANS_LAND_FILL).not.toBe(nationColor('de'));
// and it must be visibly different from the faint preset-unassigned fill
expect(NO_MANS_LAND_FILL).not.toBe(UNOWNED_FILL);
expect(NO_MANS_LAND_STROKE).not.toBe(UNOWNED_STROKE);
});
it('unownedTooltipLabel: the tooltip sub-line names the two kinds of unowned', () => {
expect(unownedTooltipLabel(true)).toBe("No man's land");
expect(unownedTooltipLabel(false)).toBe('No owner');
});
});
+7
View File
@@ -3,6 +3,13 @@
// color (see shared.nationColor). The client merges this onto the sub-national // color (see shared.nationColor). The client merges this onto the sub-national
// regions.geo.json (resolving each feature by its own region key) and calls // regions.geo.json (resolving each feature by its own region key) and calls
// source.setData(). Format-agnostic: it maps whatever keys sit in territory. // source.setData(). Format-agnostic: it maps whatever keys sit in territory.
//
// A key ABSENT from the returned map is unowned (no nation's territory holds
// it) — this covers both regions a preset never assigned and regions released
// to no man's land mid-game (v0.22, #16 P3). The map deliberately does NOT
// distinguish the two: the visible no-man's-land grey vs the faint
// preset-unassigned look is decided by the client from `GameState.unowned`
// (see shared/protocol.ts's regionFillStyle).
import { nationColor, type RegionMap } from '../shared/protocol'; import { nationColor, type RegionMap } from '../shared/protocol';
import type { GameState } from '../shared/types'; import type { GameState } from '../shared/types';
+91 -1
View File
@@ -1,10 +1,15 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest'; import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { import {
allTerritoryKeys, allTerritoryKeys,
expandCountriesToKeys, expandCountriesToKeys,
hasAdjacency,
hasRegionData, hasRegionData,
loadRegions, loadRegions,
neighboursOf,
referenceForKeys, referenceForKeys,
regionName, regionName,
resetRegions, resetRegions,
@@ -81,11 +86,37 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
events: [], events: [],
pending: [], pending: [],
version: 1, version: 1,
unowned: [],
}; };
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two. // 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
expect(allTerritoryKeys(state).size).toBe(58); expect(allTerritoryKeys(state).size).toBe(58);
}); });
it('hasAdjacency() is true: the region-adjacency manifest loaded (v0.21 #16 P2)', () => {
expect(hasAdjacency()).toBe(true);
});
it('neighboursOf returns the union of bordering keys (deduped, sorted, queried excluded)', () => {
// known mutual pairs from the manifest (verified in the Stage 1 spot checks)
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('neighboursOf returns [] for unknown keys or an empty set', () => {
expect(neighboursOf(['not-a-real-key-xyz'])).toEqual([]);
expect(neighboursOf([])).toEqual([]);
});
describe('graceful degradation with no region data', () => { describe('graceful degradation with no region data', () => {
beforeEach(() => resetRegions()); beforeEach(() => resetRegions());
@@ -96,8 +127,67 @@ describe('regions (region-key loader, v0.19 #16 P1)', () => {
expect(referenceForKeys(['FRA'])).toEqual({}); expect(referenceForKeys(['FRA'])).toEqual({});
expect(validKeys().size).toBe(0); expect(validKeys().size).toBe(0);
expect(regionName('FRA')).toBeUndefined(); expect(regionName('FRA')).toBeUndefined();
// and no adjacency: the LLM tool / frontier field switch off
expect(hasAdjacency()).toBe(false);
expect(neighboursOf(['moselle-rygy'])).toEqual([]);
}); });
}); });
afterAll(() => resetRegions()); afterAll(() => resetRegions());
}); });
describe('loadRegions resolves the manifest per environment (dev public/ vs prod dist/)', () => {
const realRoot = root;
// A root that mimics the prod Docker image: no public/, the region files live
// in dist/ (where Vite copies public/* at build). This is the exact layout that
// broke v0.19.0 in prod (the loader only looked in public/).
const base = mkdtempSync(path.join(tmpdir(), 'history-regions-'));
const prodRoot = path.join(base, 'prod');
beforeEach(() => {
mkdirSync(path.join(prodRoot, 'dist'), { recursive: true });
copyFileSync(
path.join(realRoot, 'public', 'region-keys.json'),
path.join(prodRoot, 'dist', 'region-keys.json'),
);
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(validKeys().size).toBe(1796);
expect(expandCountriesToKeys(['FRA'])).toHaveLength(42);
});
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);
});
});
+83 -16
View File
@@ -1,16 +1,23 @@
// Region-key data access (server). Loads the deterministic region-key manifest // Region-key data access (server). Loads the deterministic region-key manifest
// (public/region-keys.json: stable feature id -> region key) and the sub-national // (region-keys.json: stable feature id -> region key), the sub-national geo
// geo (public/regions.geo.json: feature id -> { name, parent }), then joins them // (regions.geo.json: feature id -> { name, parent }), and — optionally — the
// into the lookups the region-level ownership model (v0.19, #16 P1) needs: // 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:
// //
// - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions // - expand a nation's ISO_A3 country list -> the region keys of all its sub-regions (v0.19, #16 P1)
// - build the LLM "regions" reference table (key -> "name (parent ISO_A3)") // - build the LLM "regions" reference table (key -> "name (parent ISO_A3)")
// - the set of all valid region keys (to ignore hallucinated keys in applyTurnResult) // - 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)
// //
// Deliberately turf-free: it reads the two JSON files with plain JSON.parse and // Deliberately turf-free: it reads the JSON files with plain JSON.parse and
// never imports @turf/@types/geojson (importing one pulls @types/geojson into the // never imports @turf/@types/geojson (importing one pulls @types/geojson into
// tsc program and breaks WorldMap.tsx; see the v0.17 changelog note). Loaded once // the tsc program and breaks WorldMap.tsx; see the v0.17 changelog note).
// at boot; the accessors below are pure data reads thereafter. // Loaded once at boot; the accessors below are pure data reads thereafter.
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';
@@ -30,23 +37,41 @@ interface RegionData {
keysByCountry: Record<string, string[]>; keysByCountry: Record<string, string[]>;
/** the set of all region keys. */ /** the set of all region keys. */
validKeys: Set<string>; validKeys: Set<string>;
/** region key -> bordering region keys (from public/region-adjacency.json). */
adjacency: Record<string, string[]>;
} }
let data: RegionData | null = null; let data: RegionData | null = null;
/** /**
* Load (or reload) the region-key manifest + geo from `root`. Idempotent. * Load (or reload) the region-key manifest, geo, and adjacency. Idempotent.
* Returns true when both files loaded cleanly. A missing/broken file clears the * The files are served to the client from `public/` in dev (Vite serves it
* data and returns false, so the server still boots and degrades gracefully * directly) and from `dist/` in prod (Vite copies `public/*` → `dist/` at build
* (expansion becomes a no-op, ownership falls back to the raw input keys). * and the Docker image keeps only `dist/`), so probe `public/` then `dist/`
* (plus the root as a last resort) and use the first directory that holds the
* key manifest + geo. The adjacency manifest is OPTIONAL: when it is missing
* or broken, `adjacency` stays empty and `neighboursOf`/`hasAdjacency` simply
* report "no adjacency" (the LLM tool and the frontier prompt field switch
* off; everything else keeps working). 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): boolean { export function loadRegions(root: string): boolean {
const keyPath = path.join(root, 'public', 'region-keys.json'); const keyFile = 'region-keys.json';
const geoPath = path.join(root, 'public', 'regions.geo.json'); const geoFile = 'regions.geo.json';
if (!existsSync(keyPath) || !existsSync(geoPath)) { const adjacencyFile = 'region-adjacency.json';
const base = [
path.join(root, 'public'), // dev: Vite serves public/ at /
path.join(root, 'dist'), // prod: Vite copies public/* -> dist/ (== clientDir)
root, // last resort
].find((d) => existsSync(path.join(d, keyFile)) && existsSync(path.join(d, geoFile)));
if (!base) {
data = null; data = null;
return false; return false;
} }
const keyPath = path.join(base, keyFile);
const geoPath = path.join(base, geoFile);
try { try {
const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>; const idToKey = JSON.parse(readFileSync(keyPath, 'utf8')) as Record<string, string>;
const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as { const geo = JSON.parse(readFileSync(geoPath, 'utf8')) as {
@@ -65,7 +90,22 @@ export function loadRegions(root: string): boolean {
validKeys.add(key); validKeys.add(key);
if (parent) (keysByCountry[parent] ??= []).push(key); if (parent) (keysByCountry[parent] ??= []).push(key);
} }
data = { idToKey, keyToInfo, keysByCountry, validKeys };
// Adjacency is optional: a missing file means "no adjacency data".
let adjacency: Record<string, string[]> = {};
const adjacencyPath = path.join(base, adjacencyFile);
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; return validKeys.size > 0;
} catch (err) { } catch (err) {
console.error('[regions] failed to load region data', err); console.error('[regions] failed to load region data', err);
@@ -138,3 +178,30 @@ export function allTerritoryKeys(state: GameState): Set<string> {
export function validKeys(): ReadonlySet<string> { export function validKeys(): ReadonlySet<string> {
return data ? data.validKeys : new Set<string>(); return data ? data.validKeys : new Set<string>();
} }
/**
* True when the region-adjacency manifest loaded with at least one entry.
* Callers (the server's jump route) use this to decide whether to hand the
* engine an adjacency resolver (which turns on the LLM's getNeighbours tool
* and the frontier prompt field) or fall back to the plain chat() path.
*/
export function hasAdjacency(): boolean {
return data !== null && Object.keys(data.adjacency).length > 0;
}
/**
* The union of the region keys that share a land border with any of the given
* keys — deduplicated, the queried keys themselves excluded, and sorted for
* determinism. This is the data behind the LLM's getNeighbours tool and the
* player-frontier computation in the turn prompt (v0.21, #16 P2). Returns
* `[]` when no region data (or no adjacency manifest) is loaded.
*/
export function neighboursOf(keys: Iterable<string>): string[] {
if (!data) return [];
const out = new Set<string>();
for (const key of keys) {
for (const n of data.adjacency[key] ?? []) out.add(n);
}
for (const key of keys) out.delete(key); // never report a key as its own neighbour
return [...out].sort();
}
+47 -3
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,10 +13,24 @@ export interface StubPayload {
turn: number; turn: number;
date: string; date: string;
playerNationId: string; playerNationId: string;
nations: Record<string, { stats: Record<string, number> }>; nations: Record<string, { stats: Record<string, number>; territory?: string[] }>;
}; };
/** The raw pending round the engine emitted (one entry per queued item). */ /** The raw pending round the engine emitted (one entry per queued item). */
pending?: RoundItem[]; pending?: RoundItem[];
/**
* Region keys owned by other nations that share a land border with the
* player's territory — the engine's `frontier` field, computed from the
* adjacency manifest (v0.21, #16 P2). The stub cannot call tools, so it
* reads the frontier from the prompt instead.
*/
frontier?: string[];
/**
* (v0.22, #16 P3) Region keys currently in no man's land (`state.unowned`),
* emitted by the engine's user prompt. The stub ignores this field — it
* never releases a region (see deterministicTurn), so no-man's-land behavior
* is covered by unit tests against applyTurnResult, not by the stub.
*/
unowned?: string[];
} }
function truncate(s: string, n: number): string { function truncate(s: string, n: number): string {
@@ -31,6 +45,18 @@ function truncate(s: string, n: number): string {
* 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, advancing the date by 2 days. * nation, advancing the date by 2 days.
*
* (v0.21, #16 P2) It also makes ONE contiguous conquest per turn: the player
* gains the first frontier region (sorted order) from its current owner, and
* that owner loses it. The stub bypasses the tool loop (it answers with plain
* content in one round), so the frontier comes from the prompt payload, not
* from a getNeighbours call. With an empty frontier (no controllable border)
* no territory change is emitted, keeping every existing assertion stable.
*
* (v0.22, #16 P3) The stub NEVER releases a region to no man's land (it
* always returns `unowned: []`) — e2e assertions rely on its fully-deterministic
* one-conquest behavior, so the release/re-claim path is exercised by unit
* tests only (see server/engine.test.ts).
*/ */
export function deterministicTurn(p: StubPayload): TurnResult { export function deterministicTurn(p: StubPayload): TurnResult {
const state = p.state; const state = p.state;
@@ -42,6 +68,23 @@ export function deterministicTurn(p: StubPayload): TurnResult {
: 'us'; : 'us';
const items = p.pending ?? []; const items = p.pending ?? [];
// The one deterministic region transfer: the player takes the first
// 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
? [ ? [
@@ -76,7 +119,8 @@ 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: [], territoryChanges,
unowned: [], // the stub never creates no man's land (see doc comment)
}; };
} }
+51
View File
@@ -46,6 +46,16 @@ export interface TurnResult {
stateChanges: StateChange[]; stateChanges: StateChange[];
relations: RelationChange[]; relations: RelationChange[];
territoryChanges: TerritoryChange[]; territoryChanges: TerritoryChange[];
/**
* (v0.22, #16 P3) Region keys that become **unowned** — "no man's land" —
* this turn. The engine removes each key from its current (sole) owner so it
* ends the turn ownerless and marks it in `GameState.unowned`; the LLM
* narrates the cause (war, cataclysm, collapse) in `events`. Re-claiming is
* NOT expressed here: a nation re-claims an unowned region with an ordinary
* `gain` on that key. Only keys a nation currently owns can be released;
* empty on normal turns.
*/
unowned: string[];
} }
/** Per-region map payload: region key -> owning nation + its stable color. */ /** Per-region map payload: region key -> owning nation + its stable color. */
@@ -188,6 +198,47 @@ export function nationColor(id: string): string {
return `hsl(${hue} 58% 46%)`; return `hsl(${hue} 58% 46%)`;
} }
// --- No man's land (v0.22, #16 P3) — the map presentation contract ---
//
// An unowned region has two distinct looks, and the difference is NOT in the
// RegionMap (a key absent there is simply unowned): it is decided by the
// client from `GameState.unowned` (the keys released mid-game). The constants
// and the one decision function live here — shared and pure — so the
// client's choropleth is the only consumer and the color contract is testable
// from the server-side tests (shared/*.test.ts is not in the vitest glob).
/** The distinct, visible neutral grey for a **no man's land** region (released mid-game). Hue-less by construction, so it can never collide with a nation's `nationColor` (`hsl(h 58% 46%)`, always chromatic). */
export const NO_MANS_LAND_FILL = '#3f3f46';
/** The border of a no-man's-land region (brighter than a plain unowned one). */
export const NO_MANS_LAND_STROKE = 'rgba(255,255,255,0.25)';
/** The faint neutral fill for a region no nation owns that was never released mid-game (a preset never assigned it). */
export const UNOWNED_FILL = 'rgba(148,163,184,0.05)';
/** The border of a plain unowned region. */
export const UNOWNED_STROKE = 'rgba(255,255,255,0.14)';
/** The border of an owned region (brighter than unowned). */
export const OWNED_STROKE = 'rgba(255,255,255,0.30)';
/** The hover-tooltip sub-line for a region with no owning nation. */
export function unownedTooltipLabel(isNoMansLand: boolean): string {
return isNoMansLand ? "No man's land" : 'No owner';
}
/**
* The fill + border for one sub-region, given its RegionMap entry
* (`undefined` = unowned) and whether its key is no man's land (released
* mid-game, i.e. in `GameState.unowned`). Pure + shared; see the section
* comment above for why the distinction is not encoded in the RegionMap.
*/
export function regionFillStyle(
owner: RegionOwner | undefined,
isNoMansLand: boolean,
): { fill: string; stroke: string } {
if (owner) return { fill: owner.color, stroke: OWNED_STROKE };
return isNoMansLand
? { fill: NO_MANS_LAND_FILL, stroke: NO_MANS_LAND_STROKE }
: { fill: UNOWNED_FILL, stroke: UNOWNED_STROKE };
}
/** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */ /** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */
export function makeEvent( export function makeEvent(
spec: TurnEventSpec, spec: TurnEventSpec,
+13
View File
@@ -120,6 +120,19 @@ export interface GameState {
* in src/App.tsx. Starts at 1 on game creation. * in src/App.tsx. Starts at 1 on game creation.
*/ */
version: GameVersion; version: GameVersion;
/**
* (v0.22, #16 P3) Region keys that became **unowned** — "no man's land" —
* during play: a region that was owned and was released to no owner by a
* committed turn (the LLM narrates the cause in that turn's events). This is
* deliberately distinct from the many regions a preset never assigns, which
* are also unowned but were never *released* mid-game; only the keys in this
* list render as the distinct visible no-man's-land grey (see
* protocol.regionFillStyle). A region leaves the list when any nation
* re-claims it (an ordinary `gain`). Always `[]` on a fresh game — no
* man's land can only arise mid-game. Rows saved before v0.22 load with
* this defaulted to `[]` (see server/db.ts).
*/
unowned: string[];
} }
/** /**
+1
View File
@@ -653,6 +653,7 @@ function GameScreen({
playerNationId={game.playerNationId} playerNationId={game.playerNationId}
nations={game.nations} nations={game.nations}
presetId={game.presetId} presetId={game.presetId}
unowned={game.unowned ?? []}
onFocusNation={focusNation} onFocusNation={focusNation}
/> />
+70 -17
View File
@@ -8,7 +8,13 @@ import 'maplibre-gl/dist/maplibre-gl.css';
// Point it at a Vite-emitted copy of the worker module so it loads in prod. // Point it at a Vite-emitted copy of the worker module so it loads in prod.
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url'; import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
maplibregl.setWorkerUrl(workerUrl); maplibregl.setWorkerUrl(workerUrl);
import { mapFeatureOwner, type RegionMap, type RegionOwner } from '@shared/protocol'; import {
mapFeatureOwner,
regionFillStyle,
unownedTooltipLabel,
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'; import { idToKey } from '@/lib/regions';
@@ -114,19 +120,27 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
* - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key; * - `regionKey` – the region key (e.g. `moselle-rygy`), the RegionMap key;
* - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`); * - `fill` – the owning nation's stable color (from `RegionMap[regionKey]`);
* - `owner`/`ownerId` – that nation's name/id (empty for an unowned region); * - `owner`/`ownerId` – that nation's name/id (empty for an unowned region);
* - `stroke` – a per-border alpha (owned brighter than unowned); * - `stroke` – a per-border alpha (owned brightest, no man's land next,
* plain-unowned faintest);
* - `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 stamped * Crucially, the owner lookup keys the RegionMap by the stamped
* `properties.regionKey`, **never** by the sub-region id or the repacked * `properties.regionKey`, **never** by the sub-region id or the repacked
* top-level `id` — the RegionMap has no `FRA-03` entries, only region keys. * top-level `id` — the RegionMap has no `FRA-03` entries, only region keys.
*
* (v0.22, #16 P3) Unowned regions have two looks, decided here (the
* RegionMap simply has no entry for them): a key in `unownedSet` — released to
* no man's land mid-game (`GameState.unowned`) — gets the distinct visible grey
* (`NO_MANS_LAND_FILL`); every other ownerless region keeps the faint
* preset-unassigned look. Both come from the shared `regionFillStyle` contract.
*/ */
function applyRegions( function applyRegions(
geo: FeatureCollection, geo: FeatureCollection,
regions: RegionMap, regions: RegionMap,
playerNationId: string, playerNationId: string,
idKey: Record<string, string>, idKey: Record<string, string>,
unownedSet: ReadonlySet<string>,
): FeatureCollection { ): 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) {
@@ -141,14 +155,18 @@ function applyRegions(
f.properties.regionKey = key; f.properties.regionKey = key;
const r = key ? regions[key] : undefined; const r = key ? regions[key] : undefined;
if (r) { if (r) {
f.properties.fill = r.color; const style = regionFillStyle(r, false);
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = r.owner; f.properties.owner = r.owner;
f.properties.ownerId = r.ownerId; f.properties.ownerId = r.ownerId;
f.properties.stroke = 'rgba(255,255,255,0.30)';
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0; f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
} else { } else {
f.properties.fill = 'rgba(148,163,184,0.05)'; // Ownerless: visible grey only for keys released mid-game (no man's
f.properties.stroke = 'rgba(255,255,255,0.14)'; // land); a preset that never assigned this sub-region stays faint.
const style = regionFillStyle(undefined, key !== '' && unownedSet.has(key));
f.properties.fill = style.fill;
f.properties.stroke = style.stroke;
f.properties.owner = ''; f.properties.owner = '';
f.properties.ownerId = ''; f.properties.ownerId = '';
f.properties.isPlayer = 0; f.properties.isPlayer = 0;
@@ -188,14 +206,21 @@ 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's country has no owning * (color-chipped), and 1–2 key stats. When the region's country has no owning
* nation, a minimal "No owner" card is shown instead. The static * nation, a minimal card is shown instead; its sub-line says "No man's land"
* parent-country code (`properties.parent`) is deliberately NOT printed here — * for a region released mid-game (v0.22, #16 P3) and "No owner" for a region
* it never updates when ownership is transferred, so only the live owner chip * no preset ever assigned. The static parent-country code
* (resolved from the RegionMap) is shown. * (`properties.parent`) is deliberately NOT printed here — it never updates
* when ownership is transferred, so only the live owner chip (resolved from
* the RegionMap) is shown.
*/ */
function hoverHtml(subRegionName: string, region?: RegionOwner, nation?: Nation): string { function hoverHtml(
subRegionName: string,
region?: RegionOwner,
nation?: Nation,
noMansLand = false,
): string {
if (!region || !nation) { if (!region || !nation) {
return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`; return `<div class="hm-popup"><div class="hm-popup-title">${esc(subRegionName || 'Unclaimed')}</div><div class="hm-popup-sub">${unownedTooltipLabel(noMansLand)}</div></div>`;
} }
const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join(''); const stats = [statRow('Mil', nation.stats.military), statRow('Eco', nation.stats.economy)].join('');
return `<div class="hm-popup"> return `<div class="hm-popup">
@@ -213,6 +238,14 @@ interface Props {
/** The active scenario id, for a sensible initial camera. */ /** The active scenario id, for a sensible initial camera. */
presetId: string; presetId: string;
className?: string; className?: string;
/**
* (v0.22, #16 P3) Region keys currently in no man's land (released
* mid-game — `GameState.unowned`). Such regions render the distinct visible
* grey (vs the faint preset-unassigned look of every other ownerless
* region) and their hover tooltip says "No man's land" instead of "No
* owner".
*/
unowned?: string[];
/** /**
* Called with a region's owning-nation id when a sub-region is clicked, so the * Called with a region's owning-nation id when a sub-region is clicked, so the
* dossier switches to that nation. The optional `info` carries the clicked * dossier switches to that nation. The optional `info` carries the clicked
@@ -246,6 +279,13 @@ interface Props {
* never from the raw sub-region id (not a RegionMap key). Unowned sub-regions * never from the raw sub-region id (not a RegionMap key). Unowned sub-regions
* (no RegionMap entry for their key) simply do not change the dossier. * (no RegionMap entry for their key) simply do not change the dossier.
* *
* (v0.22, #16 P3) An unowned region renders two distinct ways: a key in the
* `unowned` prop (released to no man's land mid-game — `GameState.unowned`)
* gets the distinct visible grey with a "No man's land" tooltip sub-line; every
* other ownerless region (a preset never assigned it) keeps the faint look and
* a plain "No owner" sub-line. The colors/labels come from the shared
* `regionFillStyle` / `unownedTooltipLabel` contract in @shared/protocol.
*
* 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 +295,7 @@ export default function WorldMap({
nations, nations,
presetId, presetId,
className, className,
unowned,
onFocusNation, onFocusNation,
}: Props) { }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -268,11 +309,13 @@ export default function WorldMap({
const nationsRef = useRef(nations); const nationsRef = useRef(nations);
const playerRef = useRef(playerNationId); const playerRef = useRef(playerNationId);
const presetRef = useRef(presetId); const presetRef = useRef(presetId);
const unownedRef = useRef<Set<string>>(new Set(unowned ?? []));
const onFocusNationRef = useRef(onFocusNation); const onFocusNationRef = useRef(onFocusNation);
regionsRef.current = regions; regionsRef.current = regions;
nationsRef.current = nations; nationsRef.current = nations;
playerRef.current = playerNationId; playerRef.current = playerNationId;
presetRef.current = presetId; presetRef.current = presetId;
unownedRef.current = new Set(unowned ?? []);
onFocusNationRef.current = onFocusNation; onFocusNationRef.current = onFocusNation;
// Diffing state for the transient territory-change flash (owned by this component // Diffing state for the transient territory-change flash (owned by this component
@@ -396,9 +439,14 @@ export default function WorldMap({
} }
const region = mapFeatureOwner(regionsRef.current, f); const region = mapFeatureOwner(regionsRef.current, f);
const nation = region ? nationsRef.current[region.ownerId] : undefined; const nation = region ? nationsRef.current[region.ownerId] : undefined;
// (v0.22, #16 P3) whether this region is no man's land (released
// mid-game), so the ownerless tooltip can say so.
const pKey = f.properties?.regionKey;
const noMansLand =
!region && typeof pKey === 'string' && pKey !== '' && unownedRef.current.has(pKey);
hoverPopup hoverPopup
.setLngLat(e.lngLat) .setLngLat(e.lngLat)
.setHTML(hoverHtml(p?.name ?? subId, region, nation)) .setHTML(hoverHtml(p?.name ?? subId, region, nation, noMansLand))
.addTo(map); .addTo(map);
map.getCanvas().style.cursor = 'pointer'; map.getCanvas().style.cursor = 'pointer';
}; };
@@ -458,7 +506,7 @@ export default function WorldMap({
const idKey = idKeyRef.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 || !idKey || !src) return; if (!geo || !idKey || !src) return;
src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey)); src.setData(applyRegions(geo, regionsRef.current, playerRef.current, idKey, unownedRef.current));
}; };
const ensureLayers = async () => { const ensureLayers = async () => {
@@ -568,7 +616,9 @@ export default function WorldMap({
// sub-region whose owner changed since the last update (a transient, // sub-region whose owner changed since the last update (a transient,
// self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19 // self-cleaning pulse). The diff is over the region-keyed RegionMap (v0.19
// #16 P1); `animateFlash` lights up exactly the sub-region for each changed // #16 P1); `animateFlash` lights up exactly the sub-region for each changed
// key. // key. Re-runs when the no-man's-land set changes too (v0.22 #16 P3) so a
// release/re-claim repaints the grey even if the RegionMap diff alone
// wouldn't catch it.
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
const geo = baseGeoRef.current; const geo = baseGeoRef.current;
@@ -578,10 +628,11 @@ export default function WorldMap({
prevRegionsRef.current = cur; prevRegionsRef.current = cur;
const wasPrimed = primedRef.current; const wasPrimed = primedRef.current;
if (Object.keys(cur).length > 0) primedRef.current = true; if (Object.keys(cur).length > 0) primedRef.current = true;
const unownedSet = new Set(unowned ?? []);
if (map && geo && idKey) { 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, idKey)); if (src) void src.setData(applyRegions(geo, cur, playerNationId, idKey, unownedSet));
} }
if (wasPrimed && prev && idKey) { if (wasPrimed && prev && idKey) {
@@ -589,9 +640,11 @@ export default function WorldMap({
for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) { for (const key of new Set([...Object.keys(prev), ...Object.keys(cur)])) {
if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key); if ((prev[key]?.ownerId ?? '') !== (cur[key]?.ownerId ?? '')) changed.add(key);
} }
// A release (owned → unowned) and a re-claim (unowned → owned) both show
// up as ownerId changes above, so they flash like any other transfer.
if (changed.size > 0) flashApiRef.current?.([...changed]); if (changed.size > 0) flashApiRef.current?.([...changed]);
} }
}, [regions, playerNationId]); }, [regions, playerNationId, unowned]);
// Full-viewport, edge-to-edge background layer. `position: fixed` is set // Full-viewport, edge-to-edge background layer. `position: fixed` is set
// inline because MapLibre's own stylesheet sets `.maplibregl-map` to // inline because MapLibre's own stylesheet sets `.maplibregl-map` to