309 lines
26 KiB
Markdown
309 lines
26 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 queue actions and diplomatic messages into a pending round; a single "Jump forward" sends
|
||
the whole batch 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. **Gitea issue + `Fixes #N` in the PR** — the PR references (and, via `Fixes #N`, closes) the Gitea issue(s) the work addresses (`https://git.kkhost.pl`, repo `OpenCode/History`). If the user has already filed issue(s) for the change, reference those directly in the PR — no separate changelog issue is created; only file a fresh issue when the work has none pre-filed (e.g. a fix originating purely from chat feedback). `CHANGELOG.md` is always the durable changelog of what shipped.
|
||
3. **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; reference the issue in the PR description.
|
||
4. **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).
|
||
5. **Version bump** — the version is updated in `package.json`.
|
||
6. **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.**
|
||
7. **Tag + manual deploy after merge** — once merged, tag the version on `master` (e.g. `v0.2.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 **sub-national**
|
||
choropleth from `public/regions.geo.json` (1,796 sub-regions; feature `id` = `<ISO_A3>-<nn>`,
|
||
`properties.parent` = the country's ISO_A3). Ownership is at **region-key** granularity
|
||
(v0.19, #16 P1): every sub-region is addressed by a stable key from
|
||
`public/region-keys.json`, `Nation.territory` holds those keys, and each sub-region is
|
||
colored by its own owner. Since v0.21 (#16 P2) the LLM moves *individual* sub-regions
|
||
across shared land borders (frontier-biased transfers, verified with the `getNeighbours`
|
||
function-calling tool) instead of whole countries.
|
||
- The LLM client is a thin OpenAI chat-completions wrapper over native `fetch` with an
|
||
**injectable transport** (mirrors `mesh-bot/src/llm`), with two entry points: plain
|
||
`chat()` and `chatWithTools()` — a bounded OpenAI function-calling loop (max 6 tool
|
||
rounds, then one forced no-tools request) sharing the same retry/backoff/
|
||
`reasoning_effort` machinery (see the LLM config note below). 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 + scripts/**/*.test.ts; 127 tests)
|
||
├── 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 countries, id=ISO_A3 (input to the region generator)
|
||
│ ├── regions.geo.json # 1,796 sub-regions, id=<ISO_A3>-<nn>, parent=ISO_A3 (the choropleth)
|
||
│ ├── region-keys.json # feature id -> region key, 1,796 entries (the ownership/LLM address space, #16 P1)
|
||
│ └── region-adjacency.json # region key -> bordering region keys, 1,796 entries (the getNeighbours data, #16 P2)
|
||
├── scripts/ # the geo/manifest data pipeline — .mjs (turf allowed HERE only;
|
||
│ │ # never imported by .ts): generate → coarsen → cleanup → smooth →
|
||
│ │ # fill-gaps → build-region-keys → build-region-adjacency
|
||
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
|
||
├── shared/ # NO runtime deps — the single source of truth
|
||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||
│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3) + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers (incl. the no-man's-land map-presentation contract)
|
||
├── server/
|
||
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|games|map|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
|
||
│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor
|
||
│ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config
|
||
│ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency
|
||
│ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER
|
||
│ ├── map.ts # GameState → RegionMap (region key → 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) + manual hash router (#/game/<id>); owns game/regions/advisors/busy
|
||
├── 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→dossier + hover tooltip + player highlight
|
||
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
||
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
|
||
├── EventFeed.tsx # scrolling event log (newest first)
|
||
├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set
|
||
├── 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 (`server/` + `scripts/`; 127 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) |
|
||
| `npm run build:region-keys` / `build:region-adjacency` | (re)generate the `public/region-keys.json` / `region-adjacency.json` manifests — deterministic + idempotent (byte-identical re-run) |
|
||
| `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/action|diplomacy|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`.
|
||
- **Hash-based routing (client-only).** The active game's id lives in the URL fragment: home is
|
||
`#/` (a bare page with no hash is also home) and an active game is `#/game/<id>`. This is a manual
|
||
hash sync in `App.tsx` — no react-router, no server route (every non-API GET just serves
|
||
`index.html`), so it is Traefik/static-safe. A deep link opens that exact game and two tabs can
|
||
run two different games (each has its own React state + `useSse(game.id)`). The URL hash takes
|
||
precedence over the localStorage active-game id, which remains the fallback for a bare `/` load;
|
||
a bad/missing id clears the hash to `#/` and falls back to home.
|
||
- **Batched round model.** Actions and diplomatic messages are queued in `game.pending` via
|
||
`POST /action` and `POST /diplomacy` (fast, no LLM call, no turn advance). A single
|
||
`POST /jump` ("Jump forward") commits the whole pending batch in one LLM turn: `engine.runTurn`
|
||
sends the entire `pending` array to the model, applies the `TurnResult`, clears `pending`, and
|
||
advances the turn. An empty round (no items queued) is allowed and lets the world advance on its
|
||
own. This design reduces LLM call frequency and makes each call a coherent batch of work.
|
||
Queued items can be removed again via `POST /queue/remove` (by index — the client offers a
|
||
trash button per row with a confirm dialog); like the adds it never advances the turn.
|
||
- **Advisor "thinking" state (separate from the turn `busy`).** The Advisor tab shows a thinking hint
|
||
(mirroring the turn's `sim-hint`) and disables its "Ask advisor" button while the advisor LLM call is
|
||
in flight, and the top-bar `status-indicator` reads "Advisor thinking…". It is a distinct `advisorBusy`
|
||
flag in `App.tsx` and must never gate "Jump forward" or the turn flow.
|
||
- **Monotonic `GameState.version` — clients "never go backwards".** Every mutation (queue
|
||
add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` /
|
||
`removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite,
|
||
and legacy games without the field are treated as version 0. ALL state ingestion on the client —
|
||
SSE `state`/`turn` events, REST responses (add/remove/jump), and game loads — flows through the
|
||
single `applyState` in `App.tsx`: a *different* game id is always adopted, the *same* game only
|
||
when the incoming version is not older than the current one (compared inside the functional
|
||
`setGame` updater, so it can't act on a stale closure). This is what stops a late-arriving stale
|
||
SSE snapshot from clobbering the UI (see Gotchas, #10).
|
||
- **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.
|
||
- **Region-level territory (v0.19/v0.21, #16 P1+P2).** Since v0.19 ownership lives at sub-region
|
||
granularity: `Nation.territory` holds **region keys** (`<name-slug>-<4char>`, e.g. `moselle-rygy`;
|
||
the manifest is `public/region-keys.json`), `server/regions.ts` expands each preset's country list
|
||
to all its sub-regions' keys at game creation, and `buildRegionMap` keys the choropleth `RegionMap`
|
||
by region key. Since v0.21 transfers are **frontier-biased**: `applyTurnResult` is snapshot-first
|
||
and enforces **sole ownership** (a key transfers only when exactly one nation claims it to gain,
|
||
it has exactly one current owner, and the owner is not the gainer — a double claim is rejected and
|
||
a lone `lose` is a no-op, so a key can never be orphaned or duplicated), and the LLM can call the
|
||
`getNeighbours` tool (`GET_NEIGHBOURS_TOOL` + `makeNeighboursExecutor` in `engine.ts`, data from
|
||
`public/region-adjacency.json` via `neighboursOf`) to verify land borders before proposing a
|
||
transfer; the user payload carries the player's `frontier` (the sorted other-nation keys bordering
|
||
the player's territory, via `playerFrontier`). The tool path degrades gracefully: without the
|
||
adjacency manifest (or with a client lacking `chatWithTools`), a turn runs the legacy plain
|
||
`chat()` path and the tool/frontier simply switch off. Since v0.22 (#16 P3) a key may also end
|
||
a turn **unowned** — "no man's land": `TurnResult.unowned` lists keys released this turn;
|
||
`applyTurnResult` applies releases FIRST (a key leaves its sole owner — only a key with exactly
|
||
one current owner can be released; a never-assigned or corrupt key is a no-op), then claims
|
||
against the post-release snapshot, so a `gain` on an ownerless key is a **re-claim** (granted to
|
||
the sole gainer and removed from the cumulative `GameState.unowned` list). The user payload
|
||
carries the current `GameState.unowned` as `unowned`, and the jump route's `regions` reference
|
||
table includes those keys so the model can name them when re-claiming. The map renders a
|
||
released key in the distinct visible grey (`NO_MANS_LAND_FILL` in `shared/protocol.ts`, with a
|
||
"No man's land" tooltip sub-line) while a preset-unassigned key keeps the faint look ("No owner")
|
||
— the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` /
|
||
`unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh
|
||
game always starts with `unowned: []` (legacy SQLite rows load with it defaulted).
|
||
- **Map click → dossier target (owner resolved from the RegionMap, by region key).**
|
||
Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's
|
||
owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was
|
||
clicked for a small dossier label); the manual "Inspect" selector and a map click both set the
|
||
same target and stay in sync (a manual pick clears the label). Ownership is resolved from the
|
||
authoritative `RegionMap` (keyed by **region key**) via the feature's stamped
|
||
`properties.regionKey`, using the shared `mapFeatureOwner()` helper (`shared/protocol.ts`) —
|
||
`WorldMap` stamps each rendered feature with its key from `region-keys.json` when it builds the
|
||
choropleth source. It must **never** be resolved from the feature's top-level `id` (MapLibre
|
||
repacks it into a numeric index — the "no owner" bug, #6) nor from the raw sub-region `id` (not a
|
||
`RegionMap` key). A sub-region whose key has no owner leaves the current dossier target
|
||
unchanged — never a broken dossier. The hover path (a non-closeable tooltip showing the region
|
||
name + owner chip + key stats) uses the same resolution. `properties.parent` is static
|
||
geographic-origin data — never use it for ownership or display.
|
||
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per
|
||
queued round item: each `action` item → one military/success event, each `diplomacy` item → one
|
||
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
|
||
nation's `military` is set to `(current + 5)` (clamped) once per committed turn (not per item),
|
||
the player is allied with the first other nation (by key order in the `nations` record), and the
|
||
date advances 2 days. Since v0.21 the stub also makes **one contiguous conquest per turn**: the
|
||
player gains `frontier[0]` (the first of the sorted `frontier` list from the prompt payload —
|
||
stable) and that region's owner loses it; an empty frontier (no controllable border, e.g. the UK
|
||
in `wwii-1939`) transfers nothing. Since v0.22 (#16 P3) the stub **never releases** a region to
|
||
no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its
|
||
one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is
|
||
covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). The stub answers
|
||
plain `content` (never tool calls), so it naturally bypasses the tool loop in a single round.
|
||
Unit + e2e assertions rely on these exact values, so keep the stub deterministic.
|
||
- **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The
|
||
top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers
|
||
on resize and always floats above the other windows). The **dossier / event-feed / action**
|
||
panels are free-floating by default (the same first-run positions as before) but can each be
|
||
**docked** into a full-height left or right pane; the choice is persisted in `localStorage`
|
||
(`history.floatwin.v1.dock`) and restored on reload, so the default "float" reproduces the
|
||
original layout until the user docks something. The **legend** stays a small floating widget (not
|
||
dockable). There is **no "Map" window** — the map is zoomed/panned directly (wheel/trackpad +
|
||
drag).
|
||
- 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_MAX_TOKENS` | `4000` | max **output** tokens per request; with `reasoning_effort:low` a realistic turn uses ~800–1100, so 4000 is ~4× headroom and stays under the 90s proxy wall |
|
||
| `LLM_REASONING_EFFORT` | `low` | sent as top-level `reasoning_effort`; cuts the model's CoT by ~85% (the 502 fix). Set to empty/`off` to not send the field at all |
|
||
| `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 sends top-level
|
||
> `reasoning_effort: "low"` (`LLM_REASONING_EFFORT`) to cut the CoT by ~85% — small enough to
|
||
> fit the `max_tokens` budget (`LLM_MAX_TOKENS`, default 4000) — and reads only `content`,
|
||
> ignoring `reasoning`. `chat()` retries transient failures (timeout/abort, 5xx, network
|
||
> errors, null/empty `content`) up to 3 attempts with 2s→3s backoff and logs every call
|
||
> (`[llm] chat ok` / `[llm] chat failed`). No auth header is sent — the endpoint requires no key.
|
||
> The same machinery backs `chatWithTools()` (the function-calling loop used by turns when the
|
||
> region-adjacency manifest is loaded): a `finish_reason: "tool_calls"` response is a *successful*
|
||
> round (never a truncation error), each requested tool call is run through the injected executor,
|
||
> the assistant tool-call message plus one `tool` result message per call are appended to the
|
||
> history, and the loop re-sends until a final `content` — max `DEFAULT_MAX_TOOL_ROUNDS = 6`
|
||
> tool rounds, after which one last *no-tools* request forces the answer.
|
||
| `PORT` | `4321` | single server port |
|
||
| `DATA_DIR` | `./data` | where `history.db` lives |
|
||
| `CLIENT_DIST` | `./dist` | built client to serve (auto-found) |
|
||
|
||
## Gotchas
|
||
|
||
- **Stale SSE snapshots can arrive *after* a fresher REST response — the version guard is what
|
||
keeps the UI honest (#10).** The SSE `state` event sent on connect/resync is a snapshot taken at
|
||
the moment the request is handled, and the SSE stream and the REST responses ride different
|
||
connections (Vite proxy in dev, Traefik in prod) with no delivery-order guarantee. A snapshot
|
||
taken just before a mutation is saved (e.g. the on-connect resync racing a quick "Add action")
|
||
can reach the browser *after* that mutation's REST response. Pre-v0.4.0 the client applied any
|
||
incoming state unconditionally, so a just-queued item could vanish ("add one: nothing happened,
|
||
add another: both appears" — issue #10). Now every state carries a monotonic `version` and
|
||
`App.tsx`'s `applyState` drops same-game states whose version is older than the current one.
|
||
Don't add unguarded `setGame` calls or remove the version comparison.
|
||
- **502s were the reasoning model exhausting its output budget.** The original client sent only
|
||
`max_tokens: 4000`; `qwen3.8-27b`'s chain-of-thought alone often exceeded 4000 tokens, so
|
||
`content` came back `null`, which the client treated as fatal → HTTP 502 (~half of turns),
|
||
with no retry and no logging. Fixed by sending `reasoning_effort: "low"` (cuts the CoT ~85%),
|
||
retrying transient failures (timeout/abort, 5xx, network errors, null/empty `content`) up to
|
||
3 attempts with 2s→3s backoff, and logging every LLM call. A realistic turn now takes
|
||
~11–16s; the effective ceiling is the upstream OpenResty ~90s timeout, **not** our 180s
|
||
`LLM_TIMEOUT_MS`.
|
||
- **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; the `public/` data files
|
||
(`regions.geo.json` — the choropleth — `region-keys.json` + `region-adjacency.json` — the
|
||
ownership/adjacency manifests — and `countries.geo.json`) are copied into `dist/` by Vite at
|
||
build time, so the prod image serves them from `dist/` (no separate copy). The server's region
|
||
loader (`server/regions.ts`) probes `public/` then `dist/` for the manifest pair, because the
|
||
Docker image keeps only `dist/` (the v0.20 fix — before it, region-level ownership silently
|
||
never activated in prod).
|