- 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
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.