Map (basemap was rendering black): - Inline MapLibre style (dark-navy background + subtle OpenFreeMap ne2sr raster) instead of the remote "dark" style, whose vector tiles are empty at world zoom and gated the choropleth on a remote styledata fetch. - Explicit prod worker URL (maplibre-gl-worker via ?url) so GeoJSON sources process in the production bundle. - Hardened the choropleth against geo load-order races (empty sources up front, ownership stamped once data lands) and bumped fill opacity so the vivid per-nation colors are the star on the non-black background. Save/resume (a refresh used to drop users to the home screen): - Persist the active game id in localStorage (history.activeGameId) on create and resume; clear it when explicitly starting a new game. - Auto-resume the last game on a page refresh (GET /api/games/:id + /map), re-attaching the SSE stream; fall back to home gracefully if it's gone. - Home screen: prominent "Resume last game" button + a "Saved games" section (newest first). New GET /api/games list endpoint. e2e: two new deterministic tests — auto-resume-on-reload and explicit resume from the home screen (both under LLM_STUB=1). Quality gates: typecheck, lint, 21 unit tests, 3 e2e tests — all pass.
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 take
actions, send diplomatic messages, or consult an advisor. Every move is sent 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) |
Design notes
- Server is authoritative. All world state lives in SQLite; the client only renders state and
sends intents (
turn/jump/advisor). 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.