Add the 9th modular LLM ability: a turn-transient `TurnResult.disasters`
lever letting the LLM declare natural/man-made disasters (earthquake /
flood / volcanic / wildfire / famine / plague / epidemic) on a nation or
a region, applying a DETERMINISTIC (type, severity) stat dip (no hidden
randomness). Like the hybrid layer it owns no GameState field, needs no
db.ts change, and leaves no durable list — an ongoing plague/epidemic is
simply re-declared each turn it persists.
- shared/protocol.ts: DisasterType / DisasterSeverity / DisasterEvent /
DisastersOps, and `TurnResult.disasters` (between `ideology` and
`markers`; required, default `{ events: [] }`).
- server/abilities/disasters.ts: the ability module (validate / apply /
schemaLine / constraint / isMeaningful / inert stub / empty payload; no
stateFields). `DISASTER_EFFECTS` is the deterministic (type, severity)
dip table (population never touched). Target resolution mirrors hybrid
(a region key hits its SOLE owner; 0/2+ owners dropped) but has NO
`from` field, so there is no self-target drop. Dips clamp to 0-100,
mirror to the target's regions (regionStats), and re-derive the target
nation's stats (same 0-region / no-entry guards).
- Registered last in ABILITIES (after ideology). System-prompt goldens
regenerated (one schema line + one constraint bullet each); the
user.txt golden stays byte-identical (payload() is {}).
- New server/abilities/disasters.test.ts (33 tests); registry.test.ts
updated to the 9-ability contract.
- Secondary cleanups from the v0.40 refactor: AGENTS.md (abilities/ in
the Layout tree + a "how to add a new ability" bullet + a disasters
bullet), delete the orphaned validateTakeovers JSDoc, fold both inline
`as unknown` casts into the top-level ./abilities import, and de-dup
`str` (now imported from ./abilities/shared).
Version bumped 0.40.0 -> 0.41.0.
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.