- scripts/build-region-resources.mjs: deterministic, idempotent builder →
public/region-resources.json (1,800 entries; FNV-1a hash × area factor, 0–1, 2dp)
- server/regions.ts: hasResources()/resourceOf() optional-manifest loader
- shared/types.ts: RegionResources, IndustryType/INDUSTRY_TYPES, IndustryProject,
GameState.industry
- shared/protocol.ts: IndustryOps, TurnResult.industry, deriveNationResources
(area×population-weighted average, 2dp)
- server/engine.ts: isIndustryType, validateIndustryOps, applyIndustryOps
(per-region SET, first-declared-type wins), applyIndustryEffects
(INDUSTRY_ECONOMY_BONUS=1, +1 when region industry≥0.5; sole-owner only;
0/2+ owner regions kept but no boost; re-derive after), system+user prompt
contract lines, runTurn resourceOf opt, nationResources in user payload
- server/db.ts: legacy-row industry default migration
- server/stub.ts: industry:{projects:[]} in stub result; resources/industry
in StubPayload (never read)
- server/index.ts: resourceOf wired to jump route
- All test fixtures updated; new describe blocks in engine.test.ts,
protocol.test.ts, regions.test.ts, db.test.ts
- CHANGELOG.md v0.36.0, AGENTS.md docs, package.json 0.36.0
Fixes #59
51 KiB
AGENTS.md — History
Guidance for AI agents (and humans) working in this repo.
What this is
A self-contained, AI-driven alternate-history simulator in the style of Pax Historia:
the LLM is the world engine. You pick a scenario (one of 5 seed presets) and a nation to lead,
then queue actions and diplomatic messages into a pending round; a single "Jump forward" sends
the whole batch to the model, which returns a structured TurnResult (events + stat/relation/territory
deltas) that the engine validates, clamps, and applies to the world state. A MapLibre choropleth
renders territorial ownership; the whole thing persists to SQLite and streams over SSE on a single port.
Change process (mandatory)
- Small, incremental, well-tested changes — work is done one change at a time, in smaller parts; each part is tested before moving on. Connected tasks that touch the same area of code MAY be done together on one branch/PR with a single version bump (e.g. two tweaks to the same panel).
- Gitea issue +
Fixes #Nin the PR — the PR references (and, viaFixes #N, closes) the Gitea issue(s) the work addresses (https://git.kkhost.pl, repoOpenCode/History). If the user has already filed issue(s) for the change, reference those directly in the PR — no separate changelog issue is created; only file a fresh issue when the work has none pre-filed (e.g. a fix originating purely from chat feedback).CHANGELOG.mdis always the durable changelog of what shipped. - Feature branch + PR — every change lands on a feature branch off
master(no direct pushes tomaster). Open a pull request againstmasteron Gitea; reference the issue in the PR description. - Changelog entry — every change is listed in
CHANGELOG.mdwith a version, date and time (format:## v<version> — YYYY-MM-DD HH:MM, then bullet points of what changed). - Version bump — the version is updated in
package.json. - CI must pass — branch protection on
masterrequires the CI status check to be green before merge. Merge via auto-merge (POST /repos/OpenCode/History/pulls/{n}/mergewith body{"do":"merge","merge_style":"merge","merge_when_checks_succeed":true}) so the PR merges by itself once CI is green. CI never deploys to production. - Tag + manual deploy after merge — once merged, tag the version on
master(e.g.v0.2.0). Production (https://history.dev.kkhost.pl) is updated manually afterwards on the host:docker compose up -d --build, then verify the site.
Stack
- TypeScript everywhere,
strict: true - Frontend: React 19 + Vite 6 + Tailwind CSS v4 (
@tailwindcss/vite, no config file) + shadcn/ui - Backend: Node 22 + Express — one process serving the built client,
/api(REST), and/sse - Persistence:
better-sqlite3(one row per game, fullGameStateas JSON) - Map: MapLibre GL (
maplibre-gl) — remote OpenFreeMapdarkbasemap + a sub-national choropleth frompublic/regions.geo.json(1,800 sub-regions; featureid=<ISO_A3>-<nn>,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 frompublic/region-keys.json,Nation.territoryholds 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 thegetNeighboursfunction-calling tool) instead of whole countries. Since v0.25 (#31) the old RU-ARK mega-region is split into the mainland (RU-ARK, key unchanged) plus the New Earth (RU-ARK-NE) and Severnaya Zemlya (RU-ARK-SZ) island groups; since v0.26 (#41) the mainland's 28-part Arctic-coast band is further re-partitioned into Pechora (RU-PEO) and Yamal (RU-YAM) (the 5-part lat < 68 core staysRU-ARK/arkhangel-sk-lhbk). - The LLM client is a thin OpenAI chat-completions wrapper over native
fetchwith an injectable transport (mirrorsmesh-bot/src/llm), with two entry points: plainchat()andchatWithTools()— a bounded OpenAI function-calling loop (max 6 tool rounds, then one forced no-tools request) sharing the same retry/backoff/reasoning_effortmachinery (see the LLM config note below). A deterministic stub transport (LLM_STUB=1) is used by all tests/e2e.
Layout
history/
├── AGENTS.md / README.md
├── Dockerfile # multi-stage node:22-slim → single-port prod image
├── docker-compose.yml # dev_net + 4 Traefik labels → history.dev.kkhost.pl
├── 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; 407 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 # 1,800 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
│ ├── region-keys.json # feature id -> region key, 1,800 entries (the ownership/LLM address space, #16 P1)
│ ├── region-adjacency.json # region key -> bordering region keys, 1,800 entries (the getNeighbours data, #16 P2)
│ ├── region-centroids.json # region key -> {lat,lon,spread} anchor for map markers, 1,800 entries (#44)
│ ├── region-geo.json # region key -> {area (km²), population}, 1,800 entries (the area×population weighting for per-region stats, #46)
│ ├── region-resources.json # region key -> synthesized {oil, iron, food, industry} (each 0–1, 2dp), 1,800 entries (the resource profiles that scale industry boosts + feed the prompt aggregate, #59)
│ └── ne-admin0-pop.json # ISO_A3 -> admin-0 POP_EST, 249 codes (the committed population SOURCE for region-geo.json — a small derived table, the 13 MB Natural Earth source is NOT committed, #46)
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
│ │ # fill-gaps → split-arkhangel → split-arkhangel-band → build-region-keys
│ │ # → build-region-adjacency → build-region-centroids → build-region-geo
│ │ # → build-region-resources
│ │ # (build-region-geo reads the COMMITTED ne-admin0-pop.json; that small
│ │ # table is distilled once from the 13 MB Natural Earth admin-0 source by
│ │ # extract-admin0-pop.mjs — the raw source is never committed, #46)
│ └── *.test.ts # turf-free vitest tests pinning the dataset + the manifests
├── shared/ # NO runtime deps — the single source of truth
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
│ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
│ # Jump + NationOp confirm/reject request types + pure helpers
│ # (incl. per-region stats, v0.28 #46: weightedMedian / deriveNationStats /
│ # seedRegionStats / migrateRegionStats)
├── server/
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
│ │ # PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3; per-region stats maintenance + derived recompute, v0.28 #46) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
│ ├── 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/centroids/geo-manifest (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency, centroidOf/hasCentroids, geoOf/hasGeo (#46)
│ ├── 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 (+ legacy regionStats migration on load, v0.28 #46)
│ └── *.test.ts # unit tests (vitest)
└── src/
├── App.tsx # screen router (home → pick → game) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
├── main.tsx # React root + ThemeProvider(dark)
├── index.css # Tailwind v4 + dark "command-center" theme
├── lib/
│ ├── api.ts # same-origin REST helper (no hardcoded origin — proxy/Traefik safe)
│ ├── sse.ts # useSse() hook (resync on connect; ref-based handlers)
│ ├── colors.ts # event/relation/outcome → color maps
│ └── utils.ts # cn()
└── components/
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + "Nation proposals" trigger (v0.23 #16 P4a)
├── NationOpDialog.tsx # review LLM-proposed nation ops (rename/split/merge) — confirm (editable rename/merge name, split successor radio) or reject (v0.24 #16 P4b)
├── EventFeed.tsx # scrolling event log (newest first)
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
├── StatBar.tsx
└── ui/ # 14 shadcn/ui primitives
Commands (run from repo root)
| Command | What it does |
|---|---|
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/; 225 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 split:regions |
(re)apply the #31 Arkhangel'sk split (RU-ARK → mainland + RU-ARK-NE + RU-ARK-SZ) — idempotent no-op once applied |
npm run split:regions:band |
(re)apply the #41 Arctic-coast-band split (RU-ARK mainland's 28-part lat ≥ 68 band → RU-PEO + RU-YAM) — idempotent no-op once applied |
npm run build:region-keys / build:region-adjacency / build:region-centroids |
(re)generate the public/region-keys.json / region-adjacency.json / region-centroids.json manifests — deterministic + idempotent (byte-identical re-run) |
npm run build:region-geo |
(re)generate public/region-geo.json (region key → area km² + population) from regions.geo.json + region-keys.json + the committed ne-admin0-pop.json — deterministic + idempotent (byte-identical re-run; #46) |
npm run build:region-resources |
(re)generate public/region-resources.json (region key → synthesized { oil, iron, food, industry } 0–1 profile) from region-keys.json + region-geo.json (area factor) — deterministic + idempotent (byte-identical re-run; #59) |
npm run extract:admin0-pop |
one-time distillation of the 13 MB Natural Earth admin-0 source → the committed public/ne-admin0-pop.json (ISO_A3 → POP_EST, 249 codes); the raw source is never committed (#46) |
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.
Conventions
- The server is authoritative. All world state lives in SQLite (
server/db.ts). The client renders state and sends intents (/api/games/:id/action|diplomacy|jump|advisor); it never computes outcomes.shared/protocol.tsis the single source of truth for the LLMTurnResultand the client↔server message shapes — change it there first, then update both sides. - No hardcoded origins. The client uses same-origin relative paths (
/api/...,/sse/...). In dev, Vite proxies them to :4321; in prod/Traefik they're the same origin. So the app works unchanged behind the proxy — never bake inhttp://host:port. - Hash-based routing (client-only). The active game's id lives in the URL fragment: home is
#/(a bare page with no hash is also home) and an active game is#/game/<id>. This is a manual hash sync inApp.tsx— no react-router, no server route (every non-API GET just servesindex.html), so it is Traefik/static-safe. A deep link opens that exact game and two tabs can run two different games (each has its own React state +useSse(game.id)). The URL hash takes precedence over the localStorage active-game id, which remains the fallback for a bare/load; a bad/missing id clears the hash to#/and falls back to home. - Batched round model. Actions and diplomatic messages are queued in
game.pendingviaPOST /actionandPOST /diplomacy(fast, no LLM call, no turn advance). A singlePOST /jump("Jump forward") commits the whole pending batch in one LLM turn:engine.runTurnsends the entirependingarray to the model, applies theTurnResult, clearspending, and advances the turn. An empty round (no items queued) is allowed and lets the world advance on its own. This design reduces LLM call frequency and makes each call a coherent batch of work. Queued items can be removed again viaPOST /queue/remove(by index — the client offers a trash button per row with a confirm dialog); like the adds it never advances the turn. - Advisor "thinking" state (separate from the turn
busy). The Advisor tab shows a thinking hint (mirroring the turn'ssim-hint) and disables its "Ask advisor" button while the advisor LLM call is in flight, and the top-barstatus-indicatorreads "Advisor thinking…". It is a distinctadvisorBusyflag inApp.tsxand must never gate "Jump forward" or the turn flow. - Monotonic
GameState.version— clients "never go backwards". Every mutation (queue add/remove, turn commit) bumpsstate.versionin the engine (queueRoundItem/removeRoundItem/applyTurnResult); it starts at 1 on game creation, is persisted in SQLite, and legacy games without the field are treated as version 0. ALL state ingestion on the client — SSEstate/turnevents, REST responses (add/remove/jump), and game loads — flows through the singleapplyStateinApp.tsx: a different game id is always adopted, the same game only when the incoming version is not older than the current one (compared inside the functionalsetGameupdater, so it can't act on a stale closure). This is what stops a late-arriving stale SSE snapshot from clobbering the UI (see Gotchas, #10). - Defensive turn handling, never crash.
engine.runTurnstrips fences, extracts the first balanced{...},JSON.parses, validates (ignores invalid ops, clamps stats to 0–100), retries once with a nudge if the result is empty/invalid, and if it's still unusable emits a generic "unclear" event and advances. Bad model output never throws; only a transport/network error (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'sRegionMapand any client coloring so the choropleth always agrees. - Region-level territory (v0.19/v0.21, #16 P1+P2). Since v0.19 ownership lives at sub-region
granularity:
Nation.territoryholds region keys (<name-slug>-<4char>, e.g.moselle-rygy; the manifest ispublic/region-keys.json),server/regions.tsexpands each preset's country list to all its sub-regions' keys at game creation, andbuildRegionMapkeys the choroplethRegionMapby region key. Since v0.21 transfers are frontier-biased:applyTurnResultis 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 loneloseis a no-op, so a key can never be orphaned or duplicated), and the LLM can call thegetNeighbourstool (GET_NEIGHBOURS_TOOL+makeNeighboursExecutorinengine.ts, data frompublic/region-adjacency.jsonvianeighboursOf) to verify land borders before proposing a transfer; the user payload carries the player'sfrontier(the sorted other-nation keys bordering the player's territory, viaplayerFrontier). The tool path degrades gracefully: without the adjacency manifest (or with a client lackingchatWithTools), a turn runs the legacy plainchat()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.unownedlists keys released this turn;applyTurnResultapplies 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 againon an ownerless key is a re-claim (granted to the sole gainer and removed from the cumulativeGameState.unownedlist). The user payload carries the currentGameState.unownedasunowned, and the jump route'sregionsreference 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_FILLinshared/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 fromGameState.unowned(viaregionFillStyle/unownedTooltipLabel), never by theRegionMap, which only knows "owned" vs "absent". A fresh game always starts withunowned: [](legacy SQLite rows load with it defaulted). Since v0.25 (#31) the 78-part RU-ARK mega-region is split into three features — mainlandRU-ARK(its keyarkhangel-sk-lhbkis unchanged, so existing ownership is safe),RU-ARK-NE(New Earth / Franz Josef Land,new-earth-eikt) andRU-ARK-SZ(Severnaya Zemlya,severnaya-zemlya-awqp) — by the idempotentscripts/split-arkhangel.mjsstage (run betweenfill:regionsandbuild:region-keys, no-op once applied); the two island groups get their own region keys and adjacency entries (New Earth is genuinely isolated → empty adjacency; Severnaya Zemlya's islet rings touch the coarsened mainland ring, so it records one SZ↔mainland pair). Since v0.26 (#41) the mainland's 28-part Arctic-coast band (centroid lat ≥ 68) is further re-partitioned by the idempotentscripts/split-arkhangel-band.mjsstage (run betweensplit:regionsandbuild:region-keys, no-op once applied) into Pechora (RU-PEO,pechora-qwhs, 13 parts) and Yamal (RU-YAM,yamal-gosw, 15 parts) — a pure re-partition (no geometry moved → the union is byte-identical) that keepsRU-ARK's id/name/keyarkhangel-sk-lhbkstable and inserts the two new features immediately afterRU-ARK-SZ; the 5-part lat < 68 core staysRU-ARK(a single ~305k km² polygon — splitting it needs turf clipping, not a pure re-partition). - Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a; v0.24, #16 P4b).
TurnResult.nationOps(required in the schema) carries the LLM’s proposedNationOps — a discriminated union ofrename/split/merge, all three of which are now applied on confirm (P4b implemented split/merge). Each op names its target vianationRef— the LLM may use a nation’s id or name;resolveNationRefresolves id-first, then a case-insensitive unique-name match, and returnsnullfor an unknown or ambiguous ref (such ops are dropped defensively invalidateNationOps, never thrown). A turn’s ops are appended toGameState.pendingNationOps, which is never cleared on a turn commit — pending proposals persist across turns until resolved (like the action queue). The player then decides per op viaPOST /api/games/:id/nation-op/confirm({index, edits?{name?, playerPart?}}) or.../nation-op/reject({index}):confirmNationOpapplies the op to a clone — rename setsNation.nameto the edited value or, failing that, the proposedto(id / territory / relations /playerNationIdstay valid, so a renamed player is re-named in place); split (the parent must resolve and have ≥2 named parts, else a no-op) partitions the parent’s region-key territory into new nations in parts order — each new nation gets a fresh id (freshNationId: first free ofn1,n2, …) and the baselineNEW_NATION_STATS(not a copy of the parent’s), plus a copy of the parent’s relations; a key named by several parts goes to the first, and the parent’s unclaimed keys go to part 0 (in territory order), so sole ownership holds and nothing is orphaned; if the parent was the player’s nation,playerNationIdmoves to the part chosen byedits.playerPart(clamped into range, default 0), which also receivesedits.name; merge (every ref must resolve, ≥2 DISTINCT inputs, and the survivor must be one of them, else a no-op) folds the inputs into the survivor, which keeps its id and its own stats, gains the union of the inputs’ territories (the survivor’s keys first) and a union of their relations (the survivor’s own view of a third party wins, else the first input’s), is renamededits.name→into→ its own name, while the other inputs are removed and their ids dropped from every remaining nation’s relations maps; if the player led a removed input,playerNationIdmoves to the survivor. An op that is invalid at confirm time (an ambiguous ref, …) is a no-op on the world but is still removed — andrejectNationOpjust drops it; both splice by index and bumpversiononce (an invalid index →null→ HTTP 400). The response is{ game }and the server broadcasts a version-bumped SSEstate, so every tab’s version-guardedapplyStatestays in sync. The client lists the pending ops in a “Nation proposals” dialog (in the “Your move” panel): a rename and a merge show an editable name field (the merge also highlights the survivor among the input chips); a split lists each part (name + proposed region count) with a successor radio that appears only when the parent is the player’s own nation (preselected part 0 — the choice is always valid). The system prompt documents all three op shapes as a proposal queue (at most 1–2 per turn, each narrated in events). The stub never proposes a nation op (nationOps: []), so this path is covered by unit tests only (server/engine.test.ts). - Map markers (v0.27, #44) — LLM-placed named points, region-anchored, canvas-icon rendered.
The LLM can place named point markers on the map each turn: eight
MarkerTypes (city, factory, army, port, capital, fleet, rocket, gathering; a capital is at most one per nation, a gathering a political/social mass event that can enable a non-military takeover).TurnResult.markers = { add: MarkerSpec[]; remove: string[] }(both required, default[];MarkerSpec = { type, name, regionKey }). A marker's stableidis the FNV-1a 32-bit hash of`${type}\u0000${name}\u0000${regionKey}`rendered as four lowercase base-26 characters (mirroring the region-key scheme) — so a re-add is idempotent and a remove is unambiguous (by id).GameState.markers: MapMarker[]is required ([]at game creation; a legacy SQLite row without the field loads as[]— additive, never wipes).applyTurnResulthandles markers after territory claims: (1) remove by id (absent id → no-op); (2) recompute every surviving marker'sownerNationIdfrom the final post-claim ownership — a marker always follows its region (a captured region follows its new owner; a region released to no man's land, or a key with 0 or 2+ owners, becomesnull); (3) add — each new spec is placed at its region's centroid (frompublic/region-centroids.json, injected via the optionalcentroidOfparam onapplyTurnResult) plus a deterministic per-axis jitter (spread ≈ 0.1 × the largest part's smallest bbox dimension, so multiple markers of one region scatter instead of stacking), owned by the region's post-turn owner, stamped with the acting turn. Adds are dropped, never thrown, when the region key is unknown or has no centroid, when the id already exists, or beyond the hard capMAX_MARKERS = 400. The system prompt documents the field + types + placement rules;buildUserPromptcarries the current markers (asMarkerInfo[]) so the model can reference their ids inremove; a marker add/remove makes a turn "meaningful". Rendering is canvas-generated per-type ICON images, NOT text labels (src/components/WorldMap.tsx): every external glyph/font endpoint is a dead 404 (OpenFreeMap'sfonts/{stack}/{range}.pbfanddemotiles.maplibre.org/font/…) and the OFM dark basemap only renders because it is raster (labels baked into the tiles) — there is no working vector font, so markers are drawn as self-contained, offline, deterministic per-type icons (drawMarkerIcon: distinct shape per type, filled with the type color, outlined for legibility), registered as namedImageDataimages that survivestyledata. The marker name shows in the hover tooltip (not a persistent on-map label), and clicking a marker focuses its owning nation's dossier. There are no separate marker event handlers — MapLibre's per-layer event delegates fire independently (nostopPropagation), so the region hover/click handlers insteadqueryRenderedFeaturesthe marker layer under the cursor and act on the marker when present. The stub is marker-free (markers: { add: [], remove: [] }) — e2e (CI,LLM_STUB=1) is unaffected and the marker path is unit-tested only (server/engine.test.ts,scripts/region-centroids.test.ts,server/regions.test.ts,server/db.test.ts). - Per-region stats —
GameState.regionStats, derivedNation.stats(v0.28, #46; PR 1 of 2 — the LLM lever is PR 2). Since v0.28 the four 0–100 stats live at region-key granularity:GameState.regionStats(required;{}at game creation) maps each owned region key to its ownRegionStats, andNation.statsis derived — the area×population-weighted median of the stats of the regions the nation owns (deriveNationStats, pure inshared/protocol.ts). A region's weight is(area>0?area:1)×(population>0?population:1)from the committedpublic/region-geo.jsonmanifest (1,800 entries; area in km² at 2dp, integer population) — which is built byscripts/build-region-geo.mjsfromregions.geo.json+region-keys.json+ the committedpublic/ne-admin0-pop.json(Natural Earth admin-0POP_EST, 249 codes, one-time distilled byscripts/extract-admin0-pop.mjs; each country'sPOP_ESTis spread over its sub-regions proportional to area by largest-remainder, so per-country totals are preserved exactly). A released / 0-owner / 2+-owner key has no entry. A fresh game is seeded byseedRegionStats(every owned region gets a copy of its owner's stats — identical values, so the derived median equals the original and fresh-game numbers are unchanged). Legacy SQLite rows are migrated once on load bymigrateRegionStats(db.load, called withoutgeoOf— weight-invariant for the identical-value seed): a row that already hasregionStats(v0.28+) is returned unchanged (never recomputed on load → no drift), a legacy row is seeded + recomputed (0-region guard keeps its stats).applyTurnResulttakes an optionalgeoOf(passed throughrunTurn; the jump route wires it viahasGeo() ? (key) => geoOf(key) : undefined) and maintains the table inside the load-bearing order events → relations → territory → stateChanges → recompute → markers → nationOps: a release deletes the key's entry; a (re-)claim re-inits an absent entry as a copy of the gainer's currentstats; nation-levelstateChanges(the only LLM lever for now — the prompt is unchanged in this PR) write ton.statsand to every region the nation owns; then every nation with ≥1 entered region getsstatsrecomputed as its regions' weighted median (a 0-region — or no- entered-region — nation keeps its directly-maintainedstats, which is why the existing unit and e2e assertions pass unchanged; without the geo manifest the recompute degrades to an unweighted median, never throwing). The stub never touches per-region stats (LLM_STUB=1e2e unaffected), and the path is unit-tested (server/engine.test.ts,server/protocol.test.ts,server/db.test.ts,server/regions.test.ts,scripts/region-geo.test.ts). PR 1 of 2 — deliberately NOT deployed (the user deploys when PR 2, the per-region LLM lever, ships). - Wars —
GameState.wars, a first-class conflict entity (v0.33, #56). Since v0.33 thewarrelation is no longer a bare boolean flag but a namedWarentity inGameState.wars(required,[]at creation; a legacy SQLite row without the field loads as[]— additive, never wipes). EachWarhas a stable id (w1,w2, …), asideslist (≥2 distinct nation ids — any two distinct members are at war with each other),declaredTurn,declaredDate, optionalaims, astatus(active/armistice/capitulation), and an optionalfront(region keys).TurnResult.wars = { declare: WarDeclareOp[]; end: WarEndOp[] }(both required, default[]): a declare creates a new activeWar(sides resolved viaresolveNationRef— id first, then case-insensitive unique name; a declare with <2 resolved distinct sides is dropped); an end finds a war by id (unknown → no-op, never throw) and sets itsstatusto the outcome (armisticeorcapitulation). After declare/end,syncWarRelations(prev, next)enforces the derivedwarrelation: any two nations on opposite sides of ≥1 active war getwarin both directions (overriding lesser statuses — you cannot "make peace" without ending the war); when a war ends, the pairs it caused that are not covered by another active war fall back tohostile. A barewarrelation with no matching active war (legacy/back-compat) is never downgraded and never force-created. Mutual-defense drag-in: when a war is declared, each belligerent'sally-relation partners are added to the war'ssides(one level, no cascade; deterministic — sides in declared order, allies in sorted id order). The system prompt documents the field + declare/end ops (at most 1–2 declarations per turn, each narrated inevents);buildUserPromptcarries the current active wars (asWarInfo[]: id + sides + aims) so the model canendby id. The stub never declares or ends a war (wars: { declare: [], end: [] }) — e2e (CI,LLM_STUB=1) is unaffected and the path is unit-tested only (server/engine.test.ts). - Hybrid war —
TurnResult.hybrid, a deterministic non-kinetic operations spectrum (v0.34, #57).TurnResult.hybrid = { ops: HybridOp[] }(required; default{ ops: [] }) adds a covert / hybrid-warfare spectrum alongside the kineticregionChangeslever:HybridOp = { type, from, target, detail?, outcome? }withtype ∈ spy | sabotage | assassinate | cyber | economic | information | proxyandoutcome ∈ success | exposed(the LLM declares its intended outcome; the engine applies the deterministic effect).from/targetare refs resolved by the sharedresolveNationRef(id first, then case-insensitive unique name); atargetthat is not a resolvable nation is treated as a region key — it hits that region's SOLE owner (a key with 0 or 2+ owners — or the acting nation itself — is dropped, never thrown). Per type:sabotagedipseconomy(−3),economicdipseconomy(−5),cyberdipsmilitary(−3),assassinatedipsstability(−5);informationnudges the from↔target relation one step apart (capped athostile),proxylikewise but capped atrival;spyis informational only. A region-target dip lands on the singleregionStats[regionKey]entry (hybrid ops never move territory —regionChangesis the only lever that does); a nation-target dip lands on every region the nation owns; afterwards the target's derivedstatsis re-derived (0-region / no-entry guards), so the effect persists and the sole-ownership invariant is untouched. Exposed (outcome: "exposed") is a backfire: the intended effect is NOT applied; instead the from↔target relation moves one step towardhostile(capped;waris never created or escalated by an exposed op) and a diplomatic/failed event is auto-appended to the turn'sevents. Additive only — noGameState.hybridfield, nodb.tschange (the ops are consumed insideapplyTurnResultafter territory + wars and before markers).isMeaningfulcounts hybrid ops; the system prompt documents the field + per-type effects (the "unclear" fallback and the stub both returnhybrid: { ops: [] }), and the user payload carries the player'srelationsmap so the model can reason about the relation caps. The stub never proposes a hybrid op, so e2e (CI,LLM_STUB=1) is unaffected and the path is unit-tested only (server/engine.test.ts). - Trade & embargo economy layer —
GameState.trades/GameState.embargoes,TurnResult.economy(v0.35, #58).TurnResult.economy = { trade: EconomyTradeOp[]; embargo: EconomyEmbargoOp[] }(required; default{ trade: [], embargo: [] }) is the model's lever for the economic layer: a bilateral trade ({ a, b, lift? }— symmetric, stored sorteda < b) and a unilateral embargo ({ from, target, lift? }— ordered). Noliftdeclares/imposes,lift: trueends/lifts (a no-op when absent). Refs are resolved at apply time by the sharedresolveNationRef(id first, then case-insensitive unique name); an unknown / ambiguous / self-pairing op is dropped (never thrown). The lists live inGameState(trades/embargoes, required,[]at creation; a legacy SQLite row without them loads as[]— additive, never wiped, likewars/markers). Deterministic, no hidden randomness. Every committed turn, inapplyTurnResult(after hybrid, before markers): (1)applyEconomyOpsapplies this turn's declares/lifts to the active lists (idempotent declares — an already-active pair/ordered pair is not duplicated); (2)suspendEconomyOnWarauto-suspends any trade or embargo whose two nations are on opposite sides of an activeWar(#56) — a war severs the economic link, so the suspended pair stops producing its effect (this also drops a trade declared this turn between two nations that just went to war); (3)applyEconomyEffectsapplies the per-turn effect to the full active lists: each active trade gives both parties a small, deterministiceconomygain (TRADE_ECONOMY_BONUS), each active embargo dips the target'seconomyonly (EMBARGO_ECONOMY_PENALTY) — clamped to 0–100, mirrored to every region the nation owns (regionStats), and each affected nation's derivedstatsre-derived afterwards (same 0-region / no-entry guards as the hybrid dip). Sole ownership is untouched (the layer moves stats, never territory).isMeaningfulcounts non-emptyeconomy.trade/economy.embargo; the system prompt documents the field + constraints (at most 1–2 ops per turn, narrated inevents; warring pairs auto-suspended), and the user payload carries the activetrades/embargoesso the model canliftthem. The stub never declares or lifts a trade/embargo (economy: { trade: [], embargo: [] }), so e2e (CI,LLM_STUB=1) is unaffected and the path is unit-tested only (server/engine.test.ts). - Resources & Industry — per-region resource profiles + the
industrylever (v0.36, #59). Since v0.36 each of the 1,800 sub-regions carries a synthesized 4-value resource profile ({ oil, iron, food, industry }, each 0–1, 2dp) from the committedpublic/region-resources.json— built by the deterministic, idempotentscripts/build-region-resources.mjs(an FNV-1a hash of`${key}\u0000${resource}`scaled by an area factor fromregion-geo.json, clamped to 1, byte-identical re-run) and loaded byserver/regions.tsvia the same optional-manifest pattern as the other manifests (hasResources()/resourceOf(), probepublic/thendist/; a missing or malformed file degrades — boosts use the unscaled base value and the aggregate is omitted from the prompt, never a crash).TurnResult.industry = { projects: IndustryProject[] }(required, default{ projects: [] };IndustryProject = { regionKey, type },type ∈ INDUSTRY_TYPES = oil | iron | food | industry) declares a project on a region.applyTurnResultmaintainsGameState.industry(required,[]at creation; a legacy SQLite row without the field loads as[]—db.ts, additive, never wiped) as a per-region SET: a region key not in the valid-keys manifest is dropped; a region that already hosts a project is untouched (the first declared type wins — re-declaring is a no-op, the type is never replaced). Every committed turn,applyIndustryEffects(placed after the economy block, before markers; the owner is resolved from the FINAL post-claim ownership) grants each active project's region's SOLE owner a small, deterministic economy boost —INDUSTRY_ECONOMY_BONUS = 1plus one extra point when the region'sindustryresource value is ≥ 0.5 (without the manifest, just the base) — clamped to 0–100, mirrored to every region the owner holds (regionStats), and each affected nation's derivedstatsre-derived afterwards (same 0-region / no-entry guards as the economy/hybrid dips). A project on a region with 0 or 2+ owners yields no boost but is kept in the list (never removed, never thrown); sole ownership is untouched (the layer moves stats, never territory).isMeaningfulcounts non-emptyindustry.projects; the system prompt documents the field + constraints (a region holds at most one project — never re-declare on a region that already has one; at most 1–2 per turn, each narrated inevents), and the user payload carries the active projects (industry) plus each nation's aggregate resource profile (resources—deriveNationResourcesinshared/protocol.ts, the area×population-weighted average of the owned regions' profiles; omitted entirely when the manifest is not loaded). The stub never declares a project (industry: { projects: [] }) — e2e (CI,LLM_STUB=1) is unaffected and the path is unit-tested only (server/engine.test.ts,server/protocol.test.ts,server/regions.test.ts,server/db.test.ts,scripts/build-region-resources.test.ts). - Map click → dossier target (owner resolved from the RegionMap, by region key).
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
owning nation (
focusNation(ownerId, {name})inApp.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 authoritativeRegionMap(keyed by region key) via the feature's stampedproperties.regionKey, using the sharedmapFeatureOwner()helper (shared/protocol.ts) —WorldMapstamps each rendered feature with its key fromregion-keys.jsonwhen it builds the choropleth source. It must never be resolved from the feature's top-levelid(MapLibre repacks it into a numeric index — the "no owner" bug, #6) nor from the raw sub-regionid(not aRegionMapkey). 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.parentis static geographic-origin data — never use it for ownership or display. - Deterministic stub.
server/stub.tsanswers the model contract deterministically per queued round item: eachactionitem → one military/success event, eachdiplomacyitem → one diplomatic/partial event, an empty round → one political "The world moves on" event. The player nation'smilitaryis 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 thenationsrecord), and the date advances 2 days. Since v0.21 the stub also makes one contiguous conquest per turn: the player gainsfrontier[0](the first of the sortedfrontierlist from the prompt payload — stable) and that region's owner loses it; an empty frontier (no controllable border, e.g. the UK inwwii-1939) transfers nothing. Since v0.22 (#16 P3) the stub never releases a region to no man's land (it always returnsunowned: []and ignores the payload'sunownedfield) — its one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is covered by unit tests only (server/engine.test.ts,server/map.test.ts). Since v0.23 (#16 P4a) the stub also returnsnationOps: []— it never proposes a nation op (rename/split/merge; still true since P4b implemented split/merge), so the confirm/reject path is covered by unit tests only, mirroring the P3 approach; all e2e assertions are unaffected. The stub answers plaincontent(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
FloatingWindowoverlays. 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 panels are free-floating by default (the same first-run positions as before) but can each be docked into a full-height left or right pane; the choice is persisted inlocalStorage(history.floatwin.v1.dock) and restored on reload, so the default "float" reproduces the original layout until the user docks something. The legend stays a small floating widget (not dockable). There is no "Map" window — the map is zoomed/panned directly (wheel/trackpad + drag). - Styling: Tailwind utility classes + shadcn/ui only; dark "command-center" theme (slate bg, amber
primary, cyan accent). No CSS beyond the Tailwind import in
src/index.css.
Deployment
- Single Docker container (multi-stage
node:22-slim): Express serves the built client,/api, and/sseon one internal port, 4321.server/index.tsfindRoot()resolves the project root sopresets/,dist/, and thedata/(SQLite) paths work from the container layout. - Proxied by the shared Traefik (
dev_traefik, see../PROXY.md) at https://history.dev.kkhost.pl → container port 4321.docker-compose.ymldeclaresdev_netas an external network and the 4 mandatory labels (traefik.enable, routerHost(...),entrypoints=web, serviceloadbalancer.server.port=4321). No TLS/certs in the labels — the external proxy terminates TLS and forwards plain HTTP on thewebentrypoint. - Game data persists on the
history-datavolume at/app/data.
LLM configuration (env)
| Var | Default | Notes |
|---|---|---|
LLM_API_BASE |
https://ai.kkhost.pl/v1 |
OpenAI-compatible vLLM base URL (no API key required) |
LLM_MODEL |
qwen3.8-27b |
vLLM reasoning model — CoT in reasoning, final answer in content |
LLM_TIMEOUT_MS |
180000 |
the 27B reasoning model can be slow |
LLM_MAX_TOKENS |
4000 |
max output tokens per request; with reasoning_effort:low a realistic turn uses ~800–1100, so 4000 is ~4× headroom and stays under the 90s proxy wall |
LLM_REASONING_EFFORT |
low |
sent as top-level reasoning_effort; cuts the model's CoT by ~85% (the 502 fix). Set to empty/off to not send the field at all |
LLM_STUB |
(unset = live) | 1 → deterministic stub transport (no model needed) |
Reasoning model:
qwen3.8-27bemits its chain-of-thought in the response'sreasoningfield and the final JSON answer incontent.server/llm.tstherefore sends top-levelreasoning_effort: "low"(LLM_REASONING_EFFORT) to cut the CoT by ~85% — small enough to fit themax_tokensbudget (LLM_MAX_TOKENS, default 4000) — and reads onlycontent, ignoringreasoning.chat()retries transient failures (timeout/abort, 5xx, network errors, null/emptycontent) 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 backschatWithTools()(the function-calling loop used by turns when the region-adjacency manifest is loaded): afinish_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 onetoolresult message per call are appended to the history, and the loop re-sends until a finalcontent— maxDEFAULT_MAX_TOOL_ROUNDS = 6tool rounds, after which one last no-tools request forces the answer. |PORT|4321| single server port | |DATA_DIR|./data| wherehistory.dblives | |CLIENT_DIST|./dist| built client to serve (auto-found) |
Gotchas
- Stale SSE snapshots can arrive after a fresher REST response — the version guard is what
keeps the UI honest (#10). The SSE
stateevent sent on connect/resync is a snapshot taken at the moment the request is handled, and the SSE stream and the REST responses ride different connections (Vite proxy in dev, Traefik in prod) with no delivery-order guarantee. A snapshot taken just before a mutation is saved (e.g. the on-connect resync racing a quick "Add action") can reach the browser after that mutation's REST response. Pre-v0.4.0 the client applied any incoming state unconditionally, so a just-queued item could vanish ("add one: nothing happened, add another: both appears" — issue #10). Now every state carries a monotonicversionandApp.tsx'sapplyStatedrops same-game states whose version is older than the current one. Don't add unguardedsetGamecalls or remove the version comparison. - 502s were the reasoning model exhausting its output budget. The original client sent only
max_tokens: 4000;qwen3.8-27b's chain-of-thought alone often exceeded 4000 tokens, socontentcame backnull, which the client treated as fatal → HTTP 502 (~half of turns), with no retry and no logging. Fixed by sendingreasoning_effort: "low"(cuts the CoT ~85%), retrying transient failures (timeout/abort, 5xx, network errors, null/emptycontent) up to 3 attempts with 2s→3s backoff, and logging every LLM call. A realistic turn now takes ~11–16s; the effective ceiling is the upstream OpenResty ~90s timeout, not our 180sLLM_TIMEOUT_MS. - Live LLM is a runtime dependency. The live model is
qwen3.8-27bonhttps://ai.kkhost.pl/v1(a vLLM reasoning model, no API key). If that endpoint is down, turns return 502 (and the advisor degrades to a canned list). SetLLM_STUB=1for a fully functional offline instance. The map's remote OpenFreeMap style may fail to load without internet — the app still works, the choropleth just waits forstyledata. - Dev port override. Vite defaults to :5173 → proxies to :4321. Both are overridable via
VITE_PORT/HISTORY_API_PORT(the Playwright config reads them) so a run can dodge a port another project already holds. The client uses relative URLs, so it follows whichever port Vite binds. @sharedvs@alias.shared/lives at the repo root (a sibling ofsrc/), not undersrc/. Imports must use@shared/...(or../shared/fromserver/). A bare../shared/from insidesrc/resolves to the nonexistentsrc/shared/— this was a real bug thattypecheckcaught.better-sqlite3is the only native module; the Docker build relies on its prebuilt binary (works onnode:22-slim/glibc). It's the sole--externalin the tsup server bundle.- The
dist/client is committed to the build output only; thepublic/data files (regions.geo.json— the choropleth —region-keys.json+region-adjacency.json— the ownership/adjacency manifests —region-centroids.json— the marker anchors (#44) — andcountries.geo.json) are copied intodist/by Vite at build time, so the prod image serves them fromdist/(no separate copy). The server's region loader (server/regions.ts) probespublic/thendist/for the manifest pair, because the Docker image keeps onlydist/(the v0.20 fix — before it, region-level ownership silently never activated in prod).