Files
History/AGENTS.md
kamilkrzywda 9f230606c4 feat: nation-op rename review — confirm/reject routes, client dialog, AGENTS.md (P4a, #16)
Wire the 'LLM proposes, player confirms' nation-rename flow end to end:
- server: POST /api/games/:id/nation-op/confirm|reject (404/400, version-bumped SSE state)
- client: confirmNationOp/rejectNationOp api helpers + NationOpDialog (editable rename) wired into the Action panel via a 'Nation proposals' trigger
- AGENTS.md: document the pendingNationOps mechanism, routes, nationRef resolution, stub-never-proposes
2026-10-14 13:00:28 +00:00

28 KiB
Raw Permalink Blame History

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)

  1. 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).
  2. Gitea issue + Fixes #N in the PR — the PR references (and, via Fixes #N, closes) the Gitea issue(s) the work addresses (https://git.kkhost.pl, repo OpenCode/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.md is always the durable changelog of what shipped.
  3. Feature branch + PR — every change lands on a feature branch off master (no direct pushes to master). Open a pull request against master on Gitea; reference the issue in the PR description.
  4. Changelog entry — every change is listed in CHANGELOG.md with a version, date and time (format: ## v<version> — YYYY-MM-DD HH:MM, then bullet points of what changed).
  5. Version bump — the version is updated in package.json.
  6. CI must pass — branch protection on master requires the CI status check to be green before merge. Merge via auto-merge (POST /repos/OpenCode/History/pulls/{n}/merge with 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.
  7. 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, full GameState as JSON)
  • Map: MapLibre GL (maplibre-gl) — remote OpenFreeMap dark basemap + a sub-national choropleth from public/regions.geo.json (1,796 sub-regions; feature id = <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 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), 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

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; 151 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,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
│   ├── 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) + AddAction/AddDiplomacy/RemoveQueueItem/
│                           #     Jump + NationOp confirm/reject request types + pure helpers
├── 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) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / applyNationOp / confirmNationOp / rejectNationOp, v0.23 #16 P4a)
│   ├── 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)
└── 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) or reject (v0.23 #16 P4a)
        ├── 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/; 151 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.

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.ts is the single source of truth for the LLM TurnResult and 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 in http://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 in App.tsx — no react-router, no server route (every non-API GET just serves index.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.pending via POST /action and POST /diplomacy (fast, no LLM call, no turn advance). A single POST /jump ("Jump forward") commits the whole pending batch in one LLM turn: engine.runTurn sends the entire pending array to the model, applies the TurnResult, clears pending, 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 via POST /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's sim-hint) and disables its "Ask advisor" button while the advisor LLM call is in flight, and the top-bar status-indicator reads "Advisor thinking…". It is a distinct advisorBusy flag in App.tsx and must never gate "Jump forward" or the turn flow.
  • Monotonic GameState.version — clients "never go backwards". Every mutation (queue add/remove, turn commit) bumps state.version in 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 — SSE state/turn events, REST responses (add/remove/jump), and game loads — flows through the single applyState in App.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 functional setGame updater, 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.runTurn strips 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's RegionMap and 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.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).
  • Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a). TurnResult.nationOps (required in the schema) carries the LLM’s proposed NationOps — a discriminated union of rename / split / merge. rename is fully applied; split and merge are declared shapes only (accepted and stored as pending, but a no-op until P4b implements them). Each op names its target via nationRef — the LLM may use a nation’s id or name; resolveNationRef resolves id-first, then a case-insensitive unique-name match, and returns null for an unknown or ambiguous ref (such ops are dropped defensively in validateNationOps, never thrown). A turn’s ops are appended to GameState.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 via POST /api/games/:id/nation-op/confirm ({index, edits?{name?}}) or .../nation-op/reject ({index}): confirmNationOp applies the op to a clone (a rename sets Nation.name to the edited value or, failing that, the proposed to; id / territory / relations / playerNationId stay valid, so a renamed player is re-named in place) and rejectNationOp just drops it — both splice by index and bump version once (an invalid index → null → HTTP 400). The response is { game } and the server broadcasts a version-bumped SSE state, so every tab’s version-guarded applyState stays in sync. The client lists the pending ops in a “Nation proposals” dialog (in the “Your move” panel) with an editable rename name plus Confirm / Reject. The stub never proposes a nation op (nationOps: []), so this path is covered by unit tests only (server/engine.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}) 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. 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. 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). Since v0.23 (#16 P4a) the stub also returns nationOps: [] — it never proposes a nation op (rename/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 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 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 in localStorage (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 /sse on one internal port, 4321. server/index.ts findRoot() resolves the project root so presets/, dist/, and the data/ (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.yml declares dev_net as an external network and the 4 mandatory labels (traefik.enable, router Host(...), entrypoints=web, service loadbalancer.server.port=4321). No TLS/certs in the labels — the external proxy terminates TLS and forwards plain HTTP on the web entrypoint.
  • Game data persists on the history-data volume 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-27b emits its chain-of-thought in the response's reasoning field and the final JSON answer in content. server/llm.ts therefore sends top-level reasoning_effort: "low" (LLM_REASONING_EFFORT) to cut the CoT by ~85% — small enough to fit the max_tokens budget (LLM_MAX_TOKENS, default 4000) — and reads only content, 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) |

Gotchas

  • Stale SSE snapshots can arrive after a fresher REST response — the version guard is what keeps the UI honest (#10). The SSE state event 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 monotonic version and App.tsx's applyState drops same-game states whose version is older than the current one. Don't add unguarded setGame calls 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, so content came back null, which the client treated as fatal → HTTP 502 (~half of turns), with no retry and no logging. Fixed by sending reasoning_effort: "low" (cuts the CoT ~85%), retrying transient failures (timeout/abort, 5xx, network errors, null/empty content) 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 180s LLM_TIMEOUT_MS.
  • Live LLM is a runtime dependency. The live model is qwen3.8-27b on https://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). Set LLM_STUB=1 for 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 for styledata.
  • 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.
  • @shared vs @ alias. shared/ lives at the repo root (a sibling of src/), not under src/. Imports must use @shared/... (or ../shared/ from server/). A bare ../shared/ from inside src/ resolves to the nonexistent src/shared/ — this was a real bug that typecheck 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/ 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).