# 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) 1. **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). 2. **Gitea issue first** — file an issue on Gitea (`https://git.kkhost.pl`, repo `OpenCode/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. 3. **Feature branch + PR** — every change lands on a feature branch off `master` (no direct pushes to `master`). Open a pull request against `master` on Gitea; reference the issue in the PR description. 4. **Changelog entry** — every change is listed in `CHANGELOG.md` with a version, date and time (format: `## v — YYYY-MM-DD HH:MM`, then bullet points of what changed). 5. **Version bump** — the version is updated in `package.json`. 6. **CI must pass** — branch protection on `master` requires the CI status check to be green before merge. Merge via auto-merge (`POST /repos/OpenCode/History/pulls/{n}/merge` with 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.** 7. **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, full `GameState` as JSON) - Map: MapLibre GL (`maplibre-gl`) — remote OpenFreeMap `dark` basemap + a country choropleth from `public/countries.geo.json` (`id` = ISO_A3) - The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an **injectable transport** (mirrors `mesh-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; owns game/regions/advisors/busy 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 + 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.ts` is the single source of truth for the LLM `TurnResult` and 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 in `http://host:port`. - **Batched round model.** Actions and diplomatic messages are queued in `game.pending` via `POST /action` and `POST /diplomacy` (fast, no LLM call, no turn advance). A single `POST /jump` ("Jump forward") commits the whole pending batch in one LLM turn: `engine.runTurn` sends the entire `pending` array to the model, applies the `TurnResult`, clears `pending`, 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.runTurn` strips fences, extracts the first balanced `{...}`, `JSON.parse`s, 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's `RegionMap` and any client coloring so the choropleth always agrees. - **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per queued round item: each `action` item → one military/success event, each `diplomacy` item → one diplomatic/partial event, an empty round → one political "The world moves on" event. The player nation's `military` is 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 the `nations` record), 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 `/sse` on **one internal port, 4321**. `server/index.ts` `findRoot()` resolves the project root so `presets/`, `dist/`, and the `data/` (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.yml` declares `dev_net` as an external network and the 4 mandatory labels (`traefik.enable`, router `Host(...)`, `entrypoints=web`, service `loadbalancer.server.port=4321`). **No TLS/certs in the labels** — the external proxy terminates TLS and forwards plain HTTP on the `web` entrypoint. - Game data persists on the `history-data` volume 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-27b` emits its chain-of-thought in the response's `reasoning` > field and the final JSON answer in `content`. `server/llm.ts` therefore sends top-level > `reasoning_effort: "low"` (`LLM_REASONING_EFFORT`) to cut the CoT by ~85% — small enough to > fit the `max_tokens` budget (`LLM_MAX_TOKENS`, default 4000) — and reads only `content`, > ignoring `reasoning`. `chat()` retries transient failures (timeout/abort, 5xx, network > errors, null/empty `content`) 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` | where `history.db` lives | | `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, so `content` came back `null`, which the client treated as fatal → HTTP 502 (~half of turns), with no retry and no logging. Fixed by sending `reasoning_effort: "low"` (cuts the CoT ~85%), retrying transient failures (timeout/abort, 5xx, network errors, null/empty `content`) 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 180s `LLM_TIMEOUT_MS`. - **Live LLM is a runtime dependency.** The live model is `qwen3.8-27b` on `https://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). Set `LLM_STUB=1` for 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 for `styledata`. - **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. - **`@shared` vs `@` alias.** `shared/` lives at the repo **root** (a sibling of `src/`), not under `src/`. Imports must use `@shared/...` (or `../shared/` from `server/`). A bare `../shared/` from inside `src/` resolves to the nonexistent `src/shared/` — this was a real bug that `typecheck` caught. - **`better-sqlite3`** is the only native module; the Docker build relies on its prebuilt binary (works on `node:22-slim`/glibc). It's the sole `--external` in the tsup server bundle. - The `dist/` client is committed to the build output only; `public/countries.geo.json` is copied into `dist/` by Vite at build time, so the prod image serves it from `dist/` (no separate copy).