15 KiB
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)
- 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).
- Gitea issue first — file an issue on Gitea (
https://git.kkhost.pl, repoOpenCode/History) describing the change (title, scope, acceptance criteria) before or alongside creating the branch. The issue is the single source of intent; the PR references it. - Feature branch + PR — every change lands on a feature branch off
master(no direct pushes tomaster). Open a pull request againstmasteron Gitea; reference the issue in the PR description. - Changelog entry — every change is listed in
CHANGELOG.mdwith a version, date and time (format:## v<version> — YYYY-MM-DD HH:MM, then bullet points of what changed). - Version bump — the version is updated in
package.json. - CI must pass — branch protection on
masterrequires the CI status check to be green before merge. Merge via auto-merge (POST /repos/OpenCode/History/pulls/{n}/mergewith 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. - 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, fullGameStateas JSON) - Map: MapLibre GL (
maplibre-gl) — remote OpenFreeMapdarkbasemap + a country choropleth frompublic/countries.geo.json(id= ISO_A3) - The LLM client is a thin OpenAI chat-completions wrapper over native
fetchwith an injectable transport (mirrorsmesh-bot/src/llm). 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)
├── 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 features, id=ISO_A3 (committed; Vite copies to dist/)
├── shared/ # NO runtime deps — the single source of truth
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/Jump request types + pure helpers
├── server/
│ ├── index.ts # Express: /api/* (action|diplomacy|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / advisor
│ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config
│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER
│ ├── map.ts # GameState → RegionMap (ISO_A3 → 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 popup + player highlight
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list + "Jump forward"
├── EventFeed.tsx # scrolling event log (newest first)
├── 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 in server/ (19 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) |
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.tsis the single source of truth for the LLMTurnResultand 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 inhttp://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 inApp.tsx— no react-router, no server route (every non-API GET just servesindex.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.pendingviaPOST /actionandPOST /diplomacy(fast, no LLM call, no turn advance). A singlePOST /jump("Jump forward") commits the whole pending batch in one LLM turn:engine.runTurnsends the entirependingarray to the model, applies theTurnResult, clearspending, 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. - Defensive turn handling, never crash.
engine.runTurnstrips 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'sRegionMapand any client coloring so the choropleth always agrees. - Deterministic stub.
server/stub.tsanswers the model contract deterministically per queued round item: eachactionitem → one military/success event, eachdiplomacyitem → one diplomatic/partial event, an empty round → one political "The world moves on" event. The player nation'smilitaryis 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 thenationsrecord), and the date advances 2 days. Unit + e2e assertions rely on these exact values, so keep the stub deterministic. - 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/sseon one internal port, 4321.server/index.tsfindRoot()resolves the project root sopresets/,dist/, and thedata/(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.ymldeclaresdev_netas an external network and the 4 mandatory labels (traefik.enable, routerHost(...),entrypoints=web, serviceloadbalancer.server.port=4321). No TLS/certs in the labels — the external proxy terminates TLS and forwards plain HTTP on thewebentrypoint. - Game data persists on the
history-datavolume 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-27bemits its chain-of-thought in the response'sreasoningfield and the final JSON answer incontent.server/llm.tstherefore sends top-levelreasoning_effort: "low"(LLM_REASONING_EFFORT) to cut the CoT by ~85% — small enough to fit themax_tokensbudget (LLM_MAX_TOKENS, default 4000) — and reads onlycontent, ignoringreasoning.chat()retries transient failures (timeout/abort, 5xx, network errors, null/emptycontent) 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. |PORT|4321| single server port | |DATA_DIR|./data| wherehistory.dblives | |CLIENT_DIST|./dist| built client to serve (auto-found) |
Gotchas
- 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, socontentcame backnull, which the client treated as fatal → HTTP 502 (~half of turns), with no retry and no logging. Fixed by sendingreasoning_effort: "low"(cuts the CoT ~85%), retrying transient failures (timeout/abort, 5xx, network errors, null/emptycontent) 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 180sLLM_TIMEOUT_MS. - Live LLM is a runtime dependency. The live model is
qwen3.8-27bonhttps://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). SetLLM_STUB=1for 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 forstyledata. - 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. @sharedvs@alias.shared/lives at the repo root (a sibling ofsrc/), not undersrc/. Imports must use@shared/...(or../shared/fromserver/). A bare../shared/from insidesrc/resolves to the nonexistentsrc/shared/— this was a real bug thattypecheckcaught.better-sqlite3is the only native module; the Docker build relies on its prebuilt binary (works onnode:22-slim/glibc). It's the sole--externalin the tsup server bundle.- The
dist/client is committed to the build output only;public/countries.geo.jsonis copied intodist/by Vite at build time, so the prod image serves it fromdist/(no separate copy).