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>
152 lines
9.5 KiB
Markdown
152 lines
9.5 KiB
Markdown
# 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).
|