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

10 KiB
Raw Blame History

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.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'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).