- send top-level reasoning_effort: "low" (LLM_REASONING_EFFORT) to cut the qwen3.8-27b CoT ~85%, so content is no longer truncated to null -> the ~50% 502s
- new env: LLM_MAX_TOKENS (default 4000, per-call opts.maxTokens still overrides), LLM_REASONING_EFFORT (default "low", off/empty omits the field)
- chat() retries up to 3 attempts: timeout/abort, 5xx, network errors (ECONNRESET/ECONNREFUSED/socket closed), null/empty content; backoff 2s->3s (2000*1.5^(n-1), +/-20% jitter); 4xx throws immediately
- logging: '[llm] chat ok' {ms, finishReason} on success; '[llm] chat failed' {attempts, lastStatus, message, totalMs} on final failure
- tests: 12 in llm.test.ts (retry success/failure, no 4xx retry, null-content retry, logging spies); AGENTS.md env table + gotchas updated
13 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 take actions / send diplomatic messages / ask an advisor; each 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 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).
- Feature branch + PR — every change lands on a feature branch off
master(no direct pushes tomaster). Open a pull request againstmasteron Gitea (https://git.kkhost.pl, repoOpenCode/History). - 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.1.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, Preset, …)
│ └── protocol.ts # LLM TurnResult schema + client↔server messages + pure helpers
├── server/
│ ├── index.ts # Express: /api/*, /sse/:id, static+SPA; reads PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / 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; owns game/regions/advisors state
├── 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 + "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/turn|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. - 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: the player nation'smilitaryis set to(current + 5)(clamped) each turn, one event is emitted (action→military, chat→diplomatic, jump→political "The world moves on"), it allies the player with the first other nation, 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).