From 6c52e10898a3978897137ec19f56a4b378452728 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 14 Oct 2026 12:04:03 +0000 Subject: [PATCH] Bump to v0.22.0 (no man's land, #16 P3) + AGENTS.md/CHANGELOG --- AGENTS.md | 31 +++++++++++++++++++++++-------- CHANGELOG.md | 11 +++++++++++ package.json | 2 +- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0bbb00d..d0dca8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ 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 + scripts/**/*.test.ts; 105 tests) +├── 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) ├── index.html ├── presets/ # 5 scenario JSON files + index.json (manifest) @@ -67,10 +67,10 @@ history/ │ └── *.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 +│ └── 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/ │ ├── 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 +│ ├── 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: 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 @@ -105,7 +105,7 @@ 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 (`server/` + `scripts/`; 105 tests) | +| `npm test` | vitest unit tests (`server/` + `scripts/`; 127 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) | @@ -171,7 +171,19 @@ history/ 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. + `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 owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was @@ -194,9 +206,12 @@ history/ 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. + 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 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** diff --git a/CHANGELOG.md b/CHANGELOG.md index 03242ca..31714c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ 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. diff --git a/package.json b/package.json index f03b4d4..7fa76c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.21.0", + "version": "0.22.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",