Files
History/AGENTS.md
T
kamilkrzywdaandOpenCode e2936e85bb History: AI-driven alternate-history simulator
Pax Historia-style sim where the LLM is the world engine.

- React 19 + Vite 6 + Tailwind v4 + shadcn/ui frontend
- Express single-port backend (/api + /sse + built client + SPA fallback)
- SQLite (better-sqlite3) persistence, one row per game
- MapLibre choropleth map (OpenFreeMap dark + ISO_A3 regions)
- Defensive turn loop: strip fences, balanced-{} extract, parse, clamp 0-100,
  retry-once nudge, generic 'unclear' fallback; never crashes on bad output
- 5 seed presets (wwii-1939, coldwar-1955, modern-2025, no-wwii-1940, aegis-2400)
- LLM client over native fetch w/ injectable transport (mesh-bot pattern);
  LLM_STUB=1 deterministic transport for tests/e2e
- Unit (vitest, 19) + deterministic Playwright e2e
- Multi-stage node:22-slim Docker + Traefik (history.dev.kkhost.pl:4321)

Co-Authored-By: OpenCode <opencode@users.noreply.github.com>
2026-10-10 21:20:23 +00:00

152 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## 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, 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) + 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`); overrides dev ports via `VITE_PORT`/`HISTORY_API_PORT` if set |
| `docker compose up -d --build` | prod image on :4321, routed via Traefik at `history.dev.kkhost.pl` |
## 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.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`.
- **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: the player
nation's `military` is 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 `/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` | `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` | local 27B model can be slow |
| `LLM_STUB` | _(unset = live)_ | `1` → deterministic stub transport (no model needed) |
| `PORT` | `4321` | single server port |
| `DATA_DIR` | `./data` | where `history.db` lives |
| `CLIENT_DIST` | `./dist` | built client to serve (auto-found) |
## Gotchas
- **Live LLM is a runtime dependency.** If `192.168.1.70:1111` 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).