Files
History/AGENTS.md
T
kamilkrzywda e327e9aea0 Polish map & UI into a command-center war-room; rewire live LLM endpoint
Map (WorldMap):
- Keep the near-black OpenFreeMap `dark` basemap (verified HTTP 200; the
  suggested dark-matter / dark-outdoors / liberty-dark variants 404 on that
  host) so the choropleth nation colors pop
- Stable per-nation fills at 0.8 opacity, thin subtle borders, and a brighter
  thicker amber border for the player nation so "this is me" is always obvious
- Hover brightens a region and shows a color-chipped owner + key stats popup;
  click opens a full dossier popup and focuses the left panel on that nation
- Transient, self-cleaning flash (pulsing amber outline) over regions whose
  ownership changed this turn
- Bottom-left legend auto-generated from the region map; per-scenario initial
  camera (Europe for the WWII scenarios, wider for Cold War / Modern / Aegis)

Command-center UI:
- New header bar: scenario title, prominent turn + date, the player nation
  (name + color chip), and a live Idle / "Simulating…" status indicator
- 3-column operations-console grid: nation dossier (left) / dominant map
  (center) / command deck + event feed (right); stacks map-first on mobile
- EventFeed: one card per event with a category icon, a colored outcome badge
  (success/failure/partial/neutral), a nation-colored accent edge, and the
  turn + date; new events slide in and flash at the top
- NationPanel: value-colored animated stat bars (red → green), territory as
  color-coded region chips, and diplomacy as a list with a colored indicator
  per relation (ally/rival/war/neutral)
- ActionPanel: a "Your move" composer with one prominent Send button and a
  spinner + "turns can take a few seconds" hint while a turn resolves
- sonner error toasts: a 502 / model-down surfaces as "AI temporarily
  unavailable — try again"

Live LLM rewire (server, previously uncommitted):
- Default endpoint https://ai.kkhost.pl/v1 (vLLM, no API key); reasoning model
  qwen3.8-27b; always send a generous max_tokens (4000) so it finishes its
  chain-of-thought before emitting content; read only `content`, ignore
  `reasoning`. docker-compose + AGENTS.md updated to match.

Tests: the deterministic Playwright e2e gains assertions for the dossier stat
bars, the map legend (all nations), and the event-card type icon + outcome
badge; it stays fully deterministic under LLM_STUB=1.
2026-10-10 22:15:05 +00:00

159 lines
10 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` | `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_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 always sends a generous
> `max_tokens` (`DEFAULT_MAX_TOKENS = 4000`) so the model finishes reasoning before writing
> `content` (a too-small budget returns a null `content`), and reads only `content`, ignoring
> `reasoning`. 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
- **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).