Files
History/AGENTS.md
T
kamilkrzywda 547d47d64d
CI / build + unit + e2e (pull_request) Successful in 2m5s
Add Gitea Actions CI + switch to PR-based workflow
- Add .gitea/workflows/ci.yml: typecheck -> lint -> unit -> build -> deterministic
  Playwright e2e against the dev webServer (LLM_STUB=1). No dind/docker/static IP.
- package-lock.json is committed (reproducible `npm ci`); verified npm install is a no-op.
- Add CHANGELOG.md, set version 0.1.0, add .env (gitignored) + .env.example placeholder.
- Document the mandatory feature-branch -> PR -> CI -> auto-merge workflow in
  AGENTS.md + README.md (e2e now runs in CI on every PR).
- Add Playwright html/json reporters (playwright-report/, test-results/e2e.json)
  for the CI E2E-results summary step and the failure artifact upload.
2026-10-11 08:08:04 +00:00

12 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.

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. 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 (https://git.kkhost.pl, repo OpenCode/History).
  3. Changelog entry — every change is listed in CHANGELOG.md with a version, date and time (format: ## v<version> — YYYY-MM-DD HH:MM, then bullet points of what changed).
  4. Version bump — the version is updated in package.json.
  5. 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.
  6. Tag + manual deploy after merge — once merged, tag the version on master (e.g. v0.1.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, 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); 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/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).