Move territory from whole-country (ISO_A3) to individual sub-region id granularity (PR 3 of #16): - new server/regions.ts (turf-free): loadRegions (boot), expandTerritory (game creation), migrateTerritoryToRegions (legacy games on db.load), regionNamesFor / regionLabel (LLM prompt), graceful ISO_A3 fallback - game creation expands each preset nation's ISO_A3 list to its sub-region ids; pre-v0.19 saved games migrate transparently on read (idempotent) - LLM contract: territoryChanges gain/lose sub-region ids (one region at a time; gain moves out of the prior owner; lost-without-gainer = unowned); user prompt carries a top-level regions reference table ({id: 'name (parent)'} over the state's territory ids) - WorldMap: choropleth fill per sub-region (countries can split between nations), ownership via properties.regionId through the shared mapFeatureOwner, flash lights exactly the changed features (featuresByRegionIds) - deterministic stub: one region-level move per non-empty round (player annexes the lexicographically-first region of the first other nation) - tests: new server/regions.test.ts (real geo + fallback + seams); map/engine tests re-pinned to region ids; scripts/regions.test.ts stays green Measured full wwii-1939 user prompt (411 regions): ~24.7k chars / ~6.2k tokens — far under the 80k frontier limit, so the full regions table is always sent (no frontier-limited mode needed). Part of #16
History
A self-contained, AI-driven alternate-history simulator in the spirit of Pax Historia — the
LLM is the world engine. Pick one of five seed scenarios, choose 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
who owns what; everything persists to SQLite and streams over SSE on a single port.
┌────────────┐ /api + /sse ┌──────────────────────────────┐ OpenAI chat ┌──────────────┐
│ React 19 │ ───────────────▶│ Express (one process/port) │ ───────────────▶│ LLM / stub │
│ client │ (same-origin) │ /api · /sse · static · SPA │ ◀───────────────│ 27B model │
└────────────┘ │ SQLite (better-sqlite3) │ TurnResult └──────────────┘
└──────────────────────────────┘
The five scenarios
| Id | Name | Year | Nations |
|---|---|---|---|
wwii-1939 |
World War II | 1939 | 7 (Allies vs Axis) |
coldwar-1955 |
Cold War | 1955 | 7 (bloc + non-aligned) |
modern-2025 |
Modern World | 2025 | 8 (great powers) |
no-wwii-1940 |
No World War II | 1940 | 6 (divided Europe) |
aegis-2400 |
Aegis | 2400 | 6 (far-future star systems) |
Quickstart (dev)
npm install
npm run dev # Express on :4321 + Vite on :5173 (proxies /api and /sse)
Open http://localhost:5173.
The dev server talks to a live LLM by default (LLM_API_BASE / LLM_MODEL). To run fully
offline with the deterministic stub, set LLM_STUB=1:
LLM_STUB=1 npm run dev
Quickstart (Docker + Traefik)
docker compose up -d --build
The single container listens on 4321 and is routed by the shared Traefik to
https://history.dev.kkhost.pl. Game state persists on the history-data volume at
/app/data. To make a deployed instance playable without a live model:
docker compose up -d --build --force-recreate # after uncommenting LLM_STUB=1 in docker-compose.yml
Configuration (env)
| Var | Default | Notes |
|---|---|---|
LLM_API_BASE |
http://192.168.1.70:1111/v1 |
OpenAI-compatible base URL |
LLM_MODEL |
qwen3.8-27b |
model id sent in the request body |
LLM_TIMEOUT_MS |
180000 |
a local 27B model can be slow |
LLM_STUB |
(unset = live) | 1 → deterministic stub transport (no model required) |
PORT |
4321 |
the single server port (client + /api + /sse) |
DATA_DIR |
./data |
where history.db lives |
CLIENT_DIST |
(auto) | built client to serve (auto-found next to the bundle) |
VITE_PORT |
5173 |
dev-only: Vite client port (e2e uses it to dodge collisions) |
HISTORY_API_PORT |
4321 |
dev-only: the port the Vite proxy targets |
Scripts
| Command | What it does |
|---|---|
npm run dev |
Express (tsx watch, :4321) + Vite (:5173) concurrently |
npm run build |
vite build → dist/, then tsup server/index.ts → server/dist/index.js |
npm start |
run the compiled server (node server/dist/index.js) |
npm test |
vitest unit tests in server/ (19) |
npm run typecheck |
tsc --noEmit (strict) across the whole tree |
npm run lint |
ESLint (flat config) |
npm run e2e |
Playwright, fully deterministic (LLM_STUB=1); runs in CI on every PR |
Change process
Changes follow a mandatory PR-based workflow (full rules in AGENTS.md):
work on a feature branch off master (no direct pushes), add a CHANGELOG.md entry and bump the
package.json version, open a PR to master, and let CI pass (typecheck → lint → unit → build → e2e)
before auto-merging. After merge, tag the version and deploy manually (docker compose up -d --build).
Design notes
- Server is authoritative. All world state lives in SQLite; the client only renders state and
sends intents (
action/diplomacy/jump/advisor). Actions and diplomatic messages are queued intogame.pending(no LLM call, no turn advance); a singlejump("Jump forward") commits the whole batch in one LLM turn. The engine never trusts raw model output — it strips fences, extracts the first balanced{…},JSON.parses, ignores invalid ops, clamps stats to 0–100, retries once with a nudge, and if still unusable emits a generic "unclear" event and advances. Bad model output never crashes; only a network failure (model down) surfaces as a502toast. - No hardcoded origins. The client uses same-origin relative paths, so the identical build runs in dev (Vite proxy), in Docker, and behind Traefik without change.
- One source of truth.
shared/protocol.tsdefines the LLMTurnResult, the client↔server messages, and the pure helpers (nationColor,clampStat,addDays, …).
See AGENTS.md for the full layout, conventions, and gotchas.