From 0b999f1448fc42aca8011d63bfeef11045cc8002 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 14 Oct 2026 10:48:28 +0000 Subject: [PATCH] Bump to v0.21.0 (region transfers + getNeighbours tool, #16 P2) --- AGENTS.md | 107 +++++++++++++++++++++++++++++++++++---------------- CHANGELOG.md | 12 ++++++ package.json | 2 +- 3 files changed, 87 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 486406e..0bbb00d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` - Persistence: `better-sqlite3` (one row per game, full `GameState` as JSON) - Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a **sub-national** - choropleth from `public/regions.geo.json` (2,597 sub-regions; feature `id` = `-`, - `properties.parent` = the country's ISO_A3). Ownership and the LLM stay at **country** - granularity, so a sub-region inherits its parent country's owner color (see #16). + choropleth from `public/regions.geo.json` (1,796 sub-regions; feature `id` = `-`, + `properties.parent` = the country's ISO_A3). Ownership is at **region-key** granularity + (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 - **injectable transport** (mirrors `mesh-bot/src/llm`). A deterministic **stub** transport - (`LLM_STUB=1`) is used by all tests/e2e. + **injectable transport** (mirrors `mesh-bot/src/llm`), with two entry points: plain + `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 @@ -45,22 +52,29 @@ history/ ├── eslint.config.js # flat config (ESLint 9) ├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/ ├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases -├── vitest.config.ts # unit tests (server/**/*.test.ts) +├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 105 tests) ├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1) ├── index.html ├── presets/ # 5 scenario JSON files + index.json (manifest) ├── public/ │ ├── countries.geo.json # 177 countries, id=ISO_A3 (input to the region generator) -│ └── regions.geo.json # 2,597 sub-regions, id=-, parent=ISO_A3 (the choropleth) +│ ├── regions.geo.json # 1,796 sub-regions, id=-, 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 │ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …) │ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers ├── server/ -│ ├── index.ts # Express: /api/* (action|diplomacy|queue/remove|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR -│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / removeRoundItem / advisor -│ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config -│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER -│ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color) +│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|games|map|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR +│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor +│ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config +│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency +│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER +│ ├── map.ts # GameState → RegionMap (region key → owner + stable color) │ ├── presets.ts # load preset JSON files from disk │ ├── db.ts # SQLite open/save/load │ └── *.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 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 test` | vitest unit tests in `server/` (39 tests) | +| `npm test` | vitest unit tests (`server/` + `scripts/`; 105 tests) | | `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs | | `npm run lint` | ESLint flat config | | `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` | **Local verification (before every PR):** `npm run typecheck` + `npm run lint` + `npm test` (unit) + `npm run build`. The full **e2e** suite (`npm run e2e`) is verified by **CI on every PR** (the Playwright config boots its own dev webServer with `LLM_STUB=1`) — locally you only need the four checks above; e2e is the CI source of truth. @@ -143,29 +158,45 @@ history/ (model down) surfaces as an HTTP 502 to the client (shown as a toast). - **Stable per-nation colors.** `shared.nationColor(id)` hashes a nation id → hue, shared by the server's `RegionMap` and any client coloring so the choropleth always agrees. -- **Map click → dossier target (owner resolved from the RegionMap, 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** (`-<4char>`, e.g. `moselle-rygy`; + the manifest is `public/region-keys.json`), `server/regions.ts` expands each preset's country list + to all its sub-regions' keys at game creation, and `buildRegionMap` keys the choropleth `RegionMap` + by region key. Since v0.21 transfers are **frontier-biased**: `applyTurnResult` is snapshot-first + and enforces **sole ownership** (a key transfers only when exactly one nation claims it to gain, + it has exactly one current owner, and the owner is not the gainer — a double claim is rejected and + a lone `lose` is a no-op, so a key can never be orphaned or duplicated), and the LLM can call the + `getNeighbours` tool (`GET_NEIGHBOURS_TOOL` + `makeNeighboursExecutor` in `engine.ts`, data from + `public/region-adjacency.json` via `neighboursOf`) to verify land borders before proposing a + transfer; the user payload carries the player's `frontier` (the sorted other-nation keys bordering + the player's territory, via `playerFrontier`). The tool path degrades gracefully: without the + adjacency manifest (or with a client lacking `chatWithTools`), a turn runs the legacy plain + `chat()` path and the tool/frontier simply switch off. +- **Map click → dossier target (owner resolved from the RegionMap, by region key).** Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's - **parent country's** owning nation (`focusNation(ownerId)` in `GameScreen`, which also records - which region was clicked for a small dossier label); the manual "Inspect" selector and a map click - both set the same target and stay in sync (a manual pick clears the label). Ownership is resolved - from the authoritative `RegionMap` (keyed by the **parent country's** ISO_A3) via the feature's - `properties.parent`, using the shared `mapFeatureOwner()` helper (`regionId` still holds the - sub-region's own id, e.g. `FRA-03`). It must **never** be resolved from the feature's top-level - `id` (MapLibre repacks it into a numeric index — the "no owner" bug, #6) nor from the sub-region - `id` (not a `RegionMap` key). Sub-regions of a country with no `RegionMap` entry (unowned) leave - the current dossier target unchanged — never a broken dossier. The hover path (a non-closeable - tooltip showing the sub-region name + parent country + owner + key stats) uses the same resolution. - **PR 2 of #16:** the map renders `regions.geo.json` (sub-national) but ownership/territory and the - LLM stay at country level — a nation owns whole countries, so all its sub-regions share the country's - color, and a whole-country transfer flips all its sub-regions at once (PR 3 will make sub-regions - individually ownable). + owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was + clicked for a small dossier label); the manual "Inspect" selector and a map click both set the + same target and stay in sync (a manual pick clears the label). Ownership is resolved from the + authoritative `RegionMap` (keyed by **region key**) via the feature's stamped + `properties.regionKey`, using the shared `mapFeatureOwner()` helper (`shared/protocol.ts`) — + `WorldMap` stamps each rendered feature with its key from `region-keys.json` when it builds the + choropleth source. It must **never** be resolved from the feature's top-level `id` (MapLibre + repacks it into a numeric index — the "no owner" bug, #6) nor from the raw sub-region `id` (not a + `RegionMap` key). A sub-region whose key has no owner leaves the current dossier target + unchanged — never a broken dossier. The hover path (a non-closeable tooltip showing the region + name + owner chip + key stats) uses the same resolution. `properties.parent` is static + geographic-origin data — never use it for ownership or display. - **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per queued round item: each `action` item → one military/success event, each `diplomacy` item → one diplomatic/partial event, an empty round → one political "The world moves on" event. The player 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 - date advances 2 days. Unit + e2e assertions rely on these exact values, so keep the stub - deterministic. + date advances 2 days. Since v0.21 the stub also makes **one contiguous conquest per turn**: the + player gains `frontier[0]` (the first of the sorted `frontier` list from the prompt payload — + stable) and that region's owner loses it; an empty frontier (no controllable border, e.g. the UK + in `wwii-1939`) transfers nothing. The stub answers plain `content` (never tool calls), so it + naturally bypasses the tool loop in a single round. Unit + e2e assertions rely on these exact + values, so keep the stub deterministic. - **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The 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** @@ -208,6 +239,12 @@ history/ > 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 > (`[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 | | `DATA_DIR` | `./data` | where `history.db` lives | | `CLIENT_DIST` | `./dist` | built client to serve (auto-found) | @@ -247,6 +284,10 @@ history/ caught. - **`better-sqlite3`** is the only native module; the Docker build relies on its prebuilt binary (works on `node:22-slim`/glibc). It's the sole `--external` in the tsup server bundle. -- The `dist/` client is committed to the build output only; the `public/` geo files - (`regions.geo.json` — the choropleth — and `countries.geo.json`) are copied into `dist/` by Vite - at build time, so the prod image serves them from `dist/` (no separate copy). +- The `dist/` client is committed to the build output only; the `public/` data files + (`regions.geo.json` — the choropleth — `region-keys.json` + `region-adjacency.json` — the + 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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 1822dad..03242ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to **History** are listed here, newest first. +## 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`. diff --git a/package.json b/package.json index 568e77b..f03b4d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.20.0", + "version": "0.21.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",