Compare commits
23
Commits
ci-pipeline
...
v0.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
344b5b4a5e | ||
|
|
d5285ce28d | ||
|
|
8252b665b5 | ||
|
|
7d50cba061 | ||
|
|
e70f65f112 | ||
|
|
e38cc5933f | ||
|
|
858b901b8d | ||
|
|
17d6cecab9 | ||
|
|
f05ddb304d | ||
|
|
eadae4dc4f | ||
|
|
67f48fb99c | ||
|
|
c19db2c118 | ||
|
|
977bf584ce | ||
|
|
6b167cb435 | ||
|
|
762c120bbd | ||
|
|
cfcdd8b8a8 | ||
|
|
ef27b59762 | ||
|
|
3287bcaca4 | ||
|
|
f622cb676a | ||
|
|
502fbd4874 | ||
|
|
9bce14254d | ||
|
|
3297658c81 | ||
|
|
612de2fc40 |
@@ -6,19 +6,20 @@ Guidance for AI agents (and humans) working in this repo.
|
||||
|
||||
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.
|
||||
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. **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.
|
||||
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
|
||||
|
||||
@@ -49,19 +50,19 @@ history/
|
||||
├── 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
|
||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + 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
|
||||
│ ├── index.ts # Express: /api/* (action|diplomacy|queue/remove|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
|
||||
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / removeRoundItem / advisor
|
||||
│ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + 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
|
||||
├── 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/
|
||||
@@ -71,10 +72,11 @@ history/
|
||||
│ └── utils.ts # cn()
|
||||
└── components/
|
||||
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
||||
├── WorldMap.tsx # MapLibre basemap + choropleth + click popup + player highlight
|
||||
├── 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 + "Jump forward"
|
||||
├── 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
|
||||
```
|
||||
@@ -86,7 +88,7 @@ history/
|
||||
| `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 test` | vitest unit tests in `server/` (39 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) |
|
||||
@@ -97,12 +99,40 @@ history/
|
||||
## 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
|
||||
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
|
||||
@@ -110,11 +140,33 @@ history/
|
||||
(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.
|
||||
- **Map click → dossier target (owner resolved from the RegionMap).** Clicking a map region does
|
||||
NOT open a popup — it switches the dossier to that region's owning nation
|
||||
(`setViewingId(ownerId)` in `GameScreen`); the manual "Inspect" selector and a map click both set
|
||||
the same target and stay in sync. The owning nation is resolved from the authoritative `RegionMap`
|
||||
by the feature's stable ISO_A3 code, which `applyRegions` stamps into `properties.regionId`
|
||||
(properties survive into the rendered features) and the shared `regionOwner()` helper looks up.
|
||||
It must **never** be read from the feature's top-level `id`: MapLibre repacks a GeoJSON source's
|
||||
`id` into a numeric index when it stores the source, so at click/hover time `feature.id` is a
|
||||
number, never the ISO_A3 (that repacking was the "no owner" bug, #6). Unowned regions (no
|
||||
`RegionMap` entry) leave the current dossier target unchanged — never a broken dossier. The
|
||||
hover path (a non-closeable tooltip with owner + key stats) uses the same resolution.
|
||||
- **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. 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`.
|
||||
|
||||
@@ -137,19 +189,41 @@ history/
|
||||
| `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 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.
|
||||
> 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.
|
||||
| `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
|
||||
|
||||
@@ -2,6 +2,69 @@
|
||||
|
||||
All notable changes to **History** are listed here, newest first.
|
||||
|
||||
## v0.11.0 — 2026-10-12 00:31
|
||||
- **Sub-national region dataset + generator (#16, foundation only — no app behavior change).** A new dependency-free `scripts/generate-regions.mjs` (run via `npm run generate:regions`) splits each of the 177 countries in `public/countries.geo.json` into adaptive ~100–200 km sub-national regions on a regular lat/lon grid clipped to the country (Sutherland–Hodgman), and writes the committed `public/regions.geo.json` (**2,597 regions**, ~755 KB). Each region is id'd `<ISO_A3>-<nn>` (nn = 1..k in row-major order, capped at 36 per country) with `properties = { name, parent }`, so downstream work can map any region back to its parent nation. Antimeridian countries are handled three ways: small island groups (Fiji) stay a single whole-country region; large ones (Russia) are tiled in shifted [0,360) longitude space with output rings split at ±180°; and the full-360° Antarctic wrap ring becomes two hemispheric wedges. The output is fully deterministic — the same input yields a byte-identical file (verified by sha256).
|
||||
- **Validation test** — new self-contained `scripts/regions.test.ts` (picked up by `npm test`) reads the two GeoJSON files once each and asserts: the file is a well-formed FeatureCollection; every region is a valid feature whose id matches `^[A-Z]{3}-\d+$` and whose `parent` is a real country id; every country has 1–36 regions (≥2 for any country larger than 50,000 km²); every region has a sane area (≥ 200 km², with a microstate exemption, and < 5,000,000 km²); and each country's regions sum to 45–108% of its own area (the measured range is ~[64%, 105%] — Antarctica's wedges under-fill, Norway's high-latitude fjord coast over-fills by 0.1%). No change to the running app, the LLM contract, or the map — this is purely the dataset + tooling that later PRs for #16 build on.
|
||||
- Fixes #16.
|
||||
|
||||
## v0.10.0 — 2026-10-11 21:25
|
||||
- **Fix world map style (#2)** — made the choropleth fill fully opaque (`fill-opacity` 0.85 → 1.0) and darkened the Natural Earth terrain raster (`raster-opacity` 0.5 → 0.15), so the raster's flat water tint and fine 1:10m coastline no longer bleed through countries and compete with the borders.
|
||||
- **Strengthened country border lines** — border width 0.7 → 1.25, and the per-region stroke alphas raised (owned 0.10 → 0.30, unowned 0.06 → 0.14) so the vector borders read clearly as the dominant edge, with owned regions still more prominent than unowned ones.
|
||||
- Fixes #2.
|
||||
|
||||
## v0.9.0 — 2026-10-11 20:57
|
||||
- **Reduced turn latency (prompt-level):** capped `recentEvents` in the turn prompt from the last 10 to the last 5 events, and added a HARD CONSTRAINT asking the reasoning model to keep its internal analysis brief before emitting the JSON. This shortens the chain-of-thought (the 170–223s turn driver) and lowers the risk of exhausting the 4000-token output budget before the answer (the original 502 failure mode). Reasoning stays enabled (`reasoning_effort: low`); the deterministic stub, the `TurnResult` contract, `max_tokens`, and `reasoning_effort` are all unchanged.
|
||||
|
||||
## v0.8.0 — 2026-10-11 20:29
|
||||
|
||||
- **Remove a saved game (#20)** — every saved game now has a delete control: a trash button on its card in the home screen's "Saved games" section opens a confirmation dialog ("Delete this game?" showing the scenario/turn/date) with Cancel and a destructive Delete. The new `DELETE /api/games/:id` route removes the game's row from SQLite and returns `{ deleted: true }` (404 if the id is unknown).
|
||||
- **Redirect on a missing/deleted game (#20)** — loading a non-existent game id no longer leaves you stranded. A deep link to a missing game already fell back to home, and now a game that disappears *while its tab is open* (deleted from another tab or the same session) is pushed home too: the server broadcasts an SSE `error` event on delete, and the client's `useSse` gains an `onError` handler that returns the tab to the home screen (matching the existing "game not found" connect behavior).
|
||||
- `PresetPicker` gained an `onDelete` prop, a per-card `data-game-id` attribute, the delete button, and the confirm dialog; `api.deleteGame(id)`, `Db.delete(id)`, and the shared `DeleteGameResponse` type were added to support it.
|
||||
- **Unit + e2e tests** — new `Db.delete` test (removes by id; reports absence for unknown / already-gone ids) and two e2e specs: (a) deleting a saved game from the home screen (open → cancel leaves it; confirm removes the card, the response is `{deleted:true}`, and a follow-up `GET` is 404), and (b) deleting an open game (the live tab is pushed home via the SSE error, and a fresh deep-link to the deleted id lands on home).
|
||||
|
||||
## v0.7.0 — 2026-10-11 19:17
|
||||
|
||||
- Clicking a map region now switches the selected dossier to that region's owning nation instead of opening a popup (#7). The closeable click popup is gone; a hover tooltip (owner + key stats) is kept. The manual "Inspect" selector still works and stays in sync — both it and a map click drive the same dossier target.
|
||||
- Fixed: a clicked owned region no longer reports "no owner" (#6). Root cause: MapLibre repacks a GeoJSON source's top-level `id` (the ISO_A3 string) into a numeric index when it stores the source, so the feature a click/hover handler receives has a *number* for `id` — the old `regions[f.id]` lookup resolved to nothing, and the popup read "No owner" even for owned regions (the choropleth fill still worked because it is stamped from the original `id` before `setData`). Fix: `applyRegions` now stamps the stable ISO_A3 code into `properties.regionId` (and `ownerId`), which survives into the rendered features, and both the click and hover handlers resolve the owning nation from that code via the authoritative RegionMap — never from the repacked `feature.id`.
|
||||
- Unowned regions (no entry in the RegionMap) do not change the dossier when clicked — the current target is left in place (a deliberate, non-crashing choice rather than a special "unowned" dossier state).
|
||||
- New shared helper `regionOwner(regions, isoA3)` in `@shared/protocol` (the pure, single source of truth for "ISO_A3 → owner record").
|
||||
- **Unit tests** — new `regionOwner` tests (owned → owner record, unowned → undefined, empty/missing → undefined) plus a #6 regression guard that pins the exact MapLibre-repacked feature shape (numeric `id`, ISO_A3 in `properties.regionId`) and asserts resolution via `properties.regionId` works while the old `feature.id` path fails (44 tests, up from 39).
|
||||
|
||||
## v0.6.0 — 2026-10-11 18:27
|
||||
|
||||
- Advisor fetches now show a "thinking" indicator (mirroring the turn's "Simulating…" hint) while the model responds, and the Ask-advisor button disables during the fetch; the top-bar status shows "Advisor thinking…" in the meantime. Fixes #15.
|
||||
|
||||
## v0.5.0 — 2026-10-11 17:36
|
||||
|
||||
- **HUD revamp** — removed the "Map" window (the full-screen map is directly interactive via scroll/trackpad; no floating zoom control, and the now-dead `MapApi`/`onMapReady` map plumbing is removed from `WorldMap`); the top bar is now a fixed, non-movable element pinned to top-center a few px from the top (a new `pinned` mode on `FloatingWindow` — viewport-centered, no drag, no persisted position, always above other windows); and the dossier / event-feed / action panels can each be docked into a left or right full-height pane (a new `dock`/`onDockChange` mode on `FloatingWindow`, with small left/right/float controls in each title bar, choice persisted in `localStorage` and reproduced on reload; the default "float" reproduces the current first-run layout exactly). The legend stays a small floating widget. Fixes #4, Fixes #5.
|
||||
- **`FloatingWindow`** — new optional props: `pinned` (top-center fixed bar), `dock` (`'left'`/`'right'` full-height columns) and `onDockChange` (renders the dock controls and reports the mode), plus `dataTestId` (a `data-testid` on the window root for e2e). `anchor` is now optional (defaults to `top-center`).
|
||||
- **New `data-testid`s** — `top-bar` (pinned header), `panel-dossier` / `panel-feed` / `panel-action` (per-window roots), and `dock-left` / `dock-right` / `dock-float` (per-window dock controls).
|
||||
- **New localStorage key** — `history.floatwin.v1.dock` (per-id dock mode); mirrors the existing `history.floatwin.v1.collapsed`.
|
||||
- **E2E** — new "HUD revamp" suite: asserts the Map window and its zoom buttons are gone, the top bar is pinned and horizontally centered a few px from the top (and doesn't move under a drag attempt), the dossier docks to the left edge (full-height) and the feed to the right edge, and a dock choice survives a page reload — all deterministic under `LLM_STUB=1` (11 tests, up from 9).
|
||||
|
||||
## v0.4.0 — 2026-10-11 16:02
|
||||
|
||||
- **Fix: queued items disappearing from the pending list (#10).** Root cause: SSE `state` events carried no freshness information, and the client's `onState`/`onTurn` handlers applied any incoming snapshot unconditionally. A stale snapshot — e.g. the SSE on-connect resync taken just before an add was saved, or an EventSource auto-reconnect — could arrive *after* the add's REST response and clobber it, so "add one: nothing happens, add another: both appear". Fix: every mutation now bumps a monotonic `GameState.version` (persisted in SQLite, started at 1 on create, bumped by `queueRoundItem`, the new `removeRoundItem`, and `applyTurnResult`; legacy games without the field are treated as version 0), and the client routes *every* state ingestion (SSE events, add/remove/jump responses, game loads) through a single "never go backwards" rule: 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 is stale-closure-proof).
|
||||
- **New: remove button for queued items (#11).** Each pending-queue row in "Your move" has a small trash-icon button that opens a shadcn `Dialog` confirmation (item quoted, Cancel / destructive Remove, dark command-center styling). Confirming calls the new `POST /api/games/:id/queue/remove` (body `{ index }`) — same fast save → broadcast → respond order as the add routes, no LLM, no turn advance; invalid indices get a 400. The client `api.removeQueueItem(id, index)` is version-gated like every other state update, so the list can never regress after a removal.
|
||||
- **Unit tests** — new `removeRoundItem` tests (valid removal + version bump, out-of-bounds/non-integer → `null`), version-bump assertions for `queueRoundItem`/`applyTurnResult`/stub turn, a legacy-no-version case, and version round-trip in `db.test.ts` (39 tests, up from 35).
|
||||
- **E2E** — new "queue add is never clobbered by a stale SSE snapshot" test (one add appears immediately, still there after a 1s grace period for late SSE events, version strictly increases across adds, queue survives a full page reload) and a "remove with confirmation" test (cancel keeps the item, confirm removes it, no turn advance, version persisted) — all deterministic under `LLM_STUB=1`.
|
||||
|
||||
## v0.3.0 — 2026-10-11 15:29
|
||||
|
||||
- **Deep-linking** — the active game's id now lives in the URL (`#/game/<id>`), so a link opens that exact game and two tabs can run two different games; a bad or missing id falls back to the home screen. Fixes #3.
|
||||
|
||||
## v0.2.0 — 2026-10-11 14:24
|
||||
|
||||
- **Batched round model** — replaced the single-action/turn with a queued-round model. Actions and diplomatic messages accumulate in `game.pending` (no LLM call, no turn advance); a single "Jump forward" button commits the whole batch in one LLM turn. This fixes the 502 reliability problem by making each LLM call do one coherent batch of work instead of one tiny fragment.
|
||||
- **New routes** — `POST /api/games/:id/action` and `POST /api/games/:id/diplomacy` (fast, no LLM); `POST /api/games/:id/jump` (the single LLM call); `POST /api/games/:id/turn` removed.
|
||||
- **New client API functions** — `api.addAction(id, text)`, `api.addDiplomacy(id, to, message)`, `api.jump(id)`; `api.turn(...)` removed.
|
||||
- **ActionPanel rewrite** — Action tab: textarea + small "Add action" button. Diplomacy tab: target select + textarea + small "Add to round" button. Advisor tab: input + "Ask advisor" button. Below the tabs: a visible pending-queue list (⚡ for action, 💬 for diplomacy with target name, truncated text, count badge) and a large "Jump forward" primary button that shows the pending count and disables while a jump is in flight.
|
||||
- **App.tsx** — `runAddAction` / `runAddDiplomacy` handlers (no busy state); `runJump` (with busy); the "Your move" window's title bar now shows a pending-count badge chip instead of the old jump button.
|
||||
- **Deterministic stub updated** — `deterministicTurn` now emits one event per queued item (action → military/success, diplomacy → diplomatic/partial) instead of one event per turn; still sets player military to `clamp(cur+5)` and allies with the first other nation.
|
||||
- **Unit tests** — rewrote `engine.test.ts` with batch-aware tests (7 new tests, 35 total up from 28); updated `db.test.ts` and `map.test.ts` fixtures to include `pending: []`.
|
||||
- **E2E** — rewrote `e2e/sim.spec.ts` to the new flow: queue action → assert pending list → queue diplomacy → assert pending list → jump → assert turn+1, date+2, military 80→85, two events, pending cleared, ally relation.
|
||||
- **AGENTS.md** — added Gitea-issue step to the change process; updated Layout and Conventions to reflect the batched-round model.
|
||||
|
||||
## v0.1.0 — 2026-10-11 08:02
|
||||
|
||||
- Added a Gitea Actions CI pipeline (`.gitea/workflows/ci.yml`: `typecheck → lint → unit → build → deterministic Playwright e2e`). E2e runs against the app's own dev webServer (`LLM_STUB=1`) — no dind/docker.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# History
|
||||
|
||||
A self-contained, AI-driven **alternate-history simulator** in the spirit of _Pax Historia_ — the
|
||||
LLM is the world engine. Pick one of five seed scenarios, choose a nation to lead, then take
|
||||
actions, send diplomatic messages, or consult an advisor. Every 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 who owns what;
|
||||
everything persists to SQLite and streams over SSE on a single port.
|
||||
LLM is the world engine. Pick one of five seed scenarios, choose 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
|
||||
who owns what; everything persists to SQLite and streams over SSE on a single port.
|
||||
|
||||
```
|
||||
┌────────────┐ /api + /sse ┌──────────────────────────────┐ OpenAI chat ┌──────────────┐
|
||||
@@ -91,7 +91,9 @@ before auto-merging. After merge, tag the version and deploy manually (`docker c
|
||||
## Design notes
|
||||
|
||||
- **Server is authoritative.** All world state lives in SQLite; the client only renders state and
|
||||
sends intents (`turn` / `jump` / `advisor`). The engine never trusts raw model output — it strips
|
||||
sends intents (`action` / `diplomacy` / `jump` / `advisor`). Actions and diplomatic messages are
|
||||
queued into `game.pending` (no LLM call, no turn advance); a single `jump` ("Jump forward")
|
||||
commits the whole batch in one LLM turn. The engine never trusts raw model output — it strips
|
||||
fences, extracts the first balanced `{…}`, `JSON.parse`s, ignores invalid ops, clamps stats to
|
||||
0–100, retries once with a nudge, and if still unusable emits a generic "unclear" event and
|
||||
advances. Bad model output never crashes; only a network failure (model down) surfaces as a
|
||||
|
||||
+533
-35
@@ -2,19 +2,20 @@ import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Fully deterministic end-to-end test. The Playwright webServer boots the real
|
||||
* stack (Vite :5173 → Express :4321) with LLM_STUB=1, so the backend uses the
|
||||
* deterministic turn simulator in server/stub.ts: the player nation's `military`
|
||||
* stat is set to (current + 5, clamped) each turn and exactly one event is
|
||||
* emitted (action → military, chat → diplomatic, jump → political). There is no
|
||||
* live LLM and no flakiness — every assertion below is an exact, predicted value.
|
||||
* stack (Vite → Express) with LLM_STUB=1, so the backend uses the deterministic
|
||||
* turn simulator in server/stub.ts.
|
||||
*
|
||||
* Beyond the core simulation loop it also asserts the polished command-center
|
||||
* UI: the dossier's animated stat bars, the map legend (generated from the
|
||||
* region map), and the new event cards (type icon + label + colored outcome
|
||||
* badge). The map itself is a canvas, so we assert on its DOM legend, not pixels.
|
||||
* Batched-round model: actions and diplomatic messages are queued in
|
||||
* `game.pending` (no turn advance, no LLM call); a single "Jump forward"
|
||||
* commits the whole batch in one LLM turn. The stub emits one event per
|
||||
* queued item (action → military/success, diplomacy → diplomatic/partial) or
|
||||
* one political "The world moves on" event when the round is empty. It always
|
||||
* sets the player's military to (current + 5, clamped) and allies the player
|
||||
* with the first other nation, advancing the date by 2 days.
|
||||
*/
|
||||
test.describe('History — deterministic simulation (LLM_STUB=1)', () => {
|
||||
test('scenario → nation → action → jump, with predicted stat changes', async ({ page }) => {
|
||||
test('scenario → nation → queue action + diplomacy → jump, with predicted stat changes', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await page.goto('/');
|
||||
|
||||
// Home: the scenario grid loads with all 5 presets.
|
||||
@@ -44,9 +45,11 @@ test.describe('History — deterministic simulation (LLM_STUB=1)', () => {
|
||||
await expect(page.getByTestId('stat-stability')).toBeVisible();
|
||||
await expect(page.getByTestId('stat-population')).toBeVisible();
|
||||
|
||||
// The player (UK) starts at military 80, with no events yet.
|
||||
// The player (UK) starts at military 80, date 1939-09-01, no events yet.
|
||||
const military = page.getByTestId('stat-military');
|
||||
const date = page.getByTestId('game-date');
|
||||
await expect(military).toHaveText('80');
|
||||
await expect(date).toHaveText('1939-09-01');
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(0);
|
||||
|
||||
// The map legend is auto-generated from the region map: all 7 nations of the
|
||||
@@ -54,34 +57,63 @@ test.describe('History — deterministic simulation (LLM_STUB=1)', () => {
|
||||
await expect(page.getByTestId('map-legend-item')).toHaveCount(7, { timeout: 15000 });
|
||||
await expect(page.getByTestId('map-legend-item').filter({ hasText: 'United Kingdom' })).toBeVisible();
|
||||
|
||||
// Take an action: the stub bumps UK military to 85 and emits a military event.
|
||||
// --- Queue an action (no turn advance, no LLM call) ---
|
||||
await page.getByTestId('action-textarea').fill('Mobilize the home guard and fortify Dover');
|
||||
await page.getByTestId('action-submit').click();
|
||||
await page.getByTestId('add-action').click();
|
||||
|
||||
// The pending list shows one queued action; turn and date are unchanged.
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(1);
|
||||
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
|
||||
await expect(page.getByTestId('pending-item').first()).toContainText('Mobilize the home guard and fortify Dover');
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(date).toHaveText('1939-09-01');
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(0);
|
||||
// The "Your move" window's title badge shows the pending count.
|
||||
await expect(page.getByTestId('title-queued')).toHaveText('1 queued');
|
||||
|
||||
// --- Queue a diplomatic message (no turn advance, no LLM call) ---
|
||||
await page.getByTestId('tab-diplo').click();
|
||||
await page.getByTestId('chat-target').click();
|
||||
await page.getByRole('option', { name: 'United States' }).click();
|
||||
await page.getByTestId('chat-message').fill('We propose a mutual defense pact');
|
||||
await page.getByTestId('add-diplomacy').click();
|
||||
|
||||
// The pending list now shows two items (action + diplomacy); turn still 1.
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(2);
|
||||
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
|
||||
await expect(page.getByTestId('pending-item').nth(1)).toHaveAttribute('data-kind', 'diplomacy');
|
||||
await expect(page.getByTestId('pending-item').nth(1)).toContainText('→ United States');
|
||||
await expect(page.getByTestId('pending-item').nth(1)).toContainText('We propose a mutual defense pact');
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(date).toHaveText('1939-09-01');
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(0);
|
||||
await expect(page.getByTestId('title-queued')).toHaveText('2 queued');
|
||||
|
||||
// --- Jump forward: commit the whole pending round in a single LLM turn ---
|
||||
// The button sits at the bottom of the scrollable "Your move" window body
|
||||
// (max-h-[72vh] overflow-y-auto) and may be outside the page viewport; a
|
||||
// native DOM click bypasses Playwright's viewport actionability check.
|
||||
await page
|
||||
.getByTestId('jump-forward')
|
||||
.evaluate((el) => (el as HTMLElement).click());
|
||||
|
||||
// Turn advances to 2, date to 1939-09-03 (+2 days), military 80 → 85.
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
|
||||
await expect(military).toHaveText('85', { timeout: 20000 });
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByTestId('event-feed').getByText('Mobilize the home guard and fortify Dover').first(),
|
||||
).toBeVisible();
|
||||
await expect(date).toHaveText('1939-09-03', { timeout: 20000 });
|
||||
|
||||
// The new event card carries its type (icon + label) and a colored outcome badge.
|
||||
await expect(page.getByTestId('event-item').first().getByTestId('event-type-icon')).toBeVisible();
|
||||
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('military');
|
||||
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('success');
|
||||
|
||||
// Jump forward (no action): a "world moves on" political event, military → 90.
|
||||
await page.getByTestId('jump-forward').click();
|
||||
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('3', { timeout: 20000 });
|
||||
await expect(military).toHaveText('90', { timeout: 20000 });
|
||||
// Two events were emitted (one per queued item): one military, one diplomatic.
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(2);
|
||||
await expect(page.getByTestId('event-feed').getByText('The world moves on').first()).toBeVisible();
|
||||
|
||||
// Newest first: the jump event (political / neutral) sits on top, and the
|
||||
// action event (military / success) is second — both with their outcome badges.
|
||||
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('political');
|
||||
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('neutral');
|
||||
// The pending queue is now empty — the round was committed and cleared.
|
||||
await expect(page.getByTestId('pending-empty')).toBeVisible();
|
||||
await expect(page.getByTestId('title-queued')).not.toBeVisible();
|
||||
|
||||
// Newest first: the diplomatic event (2nd queued item) sits on top, the
|
||||
// military event (1st queued item) is second.
|
||||
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('diplomatic');
|
||||
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('partial');
|
||||
await expect(page.getByTestId('event-item').nth(1).getByTestId('event-type')).toHaveText('military');
|
||||
await expect(page.getByTestId('event-item').nth(1).getByTestId('event-outcome')).toHaveText('success');
|
||||
});
|
||||
});
|
||||
@@ -91,7 +123,7 @@ test.describe('History — deterministic simulation (LLM_STUB=1)', () => {
|
||||
* localStorage, so a reload (or the home screen's "Resume last game" button)
|
||||
* must drop the user straight back into the same game — same turn, same stats,
|
||||
* same map — rather than resetting to the home screen. Deterministic under the
|
||||
* stub (UK military starts at 80 and +5 per turn).
|
||||
* stub (UK military starts at 80 and +5 per committed turn).
|
||||
*/
|
||||
test.describe('History — save / resume across a page refresh', () => {
|
||||
async function createUkGameAtTurn2(page: Page): Promise<void> {
|
||||
@@ -108,9 +140,12 @@ test.describe('History — save / resume across a page refresh', () => {
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('stat-military')).toHaveText('80');
|
||||
|
||||
// One action: the stub bumps UK military to 85 and advances to turn 2.
|
||||
// Queue one action onto the pending round, then jump forward: the stub
|
||||
// bumps UK military to 85 and advances to turn 2.
|
||||
await page.getByTestId('action-textarea').fill('Reinforce the Atlantic convoys');
|
||||
await page.getByTestId('action-submit').click();
|
||||
await page.getByTestId('add-action').click();
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(1);
|
||||
await page.getByTestId('jump-forward').click();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
|
||||
await expect(page.getByTestId('stat-military')).toHaveText('85', { timeout: 20000 });
|
||||
}
|
||||
@@ -145,3 +180,466 @@ test.describe('History — save / resume across a page refresh', () => {
|
||||
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Queue reliability — issue #10. A queued item must appear in the pending list
|
||||
* right after its REST response AND stay there: before the fix, a stale SSE
|
||||
* `state` snapshot (the on-connect resync taken just before the add was saved,
|
||||
* carrying no freshness marker) could arrive after the REST response and
|
||||
* clobber it — "add one: nothing happens, add another: both appear". The fix
|
||||
* gives every mutation a monotonic `GameState.version` and the client applies
|
||||
* an incoming state only when it is not older than the current one.
|
||||
*/
|
||||
test.describe('History — queue add is never clobbered by a stale SSE snapshot (#10)', () => {
|
||||
test('one added action appears immediately and survives late SSE snapshots', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
const id = await createUkGame(page);
|
||||
const origin = await page.evaluate(() => location.origin);
|
||||
|
||||
// Game screen at turn 1, nothing queued yet.
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
|
||||
await expect(page.getByTestId('pending-empty')).toBeVisible();
|
||||
|
||||
// Queue exactly ONE action and assert it appears immediately (turn/date
|
||||
// unchanged, no events yet).
|
||||
await page.getByTestId('action-textarea').fill('Hold the line at the channel');
|
||||
const [addResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
|
||||
),
|
||||
page.getByTestId('add-action').click(),
|
||||
]);
|
||||
const addBody = (await addResp.json()) as { game: { version: number; pending: unknown[] } };
|
||||
expect(addBody.game.pending).toHaveLength(1);
|
||||
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(1);
|
||||
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
|
||||
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(0);
|
||||
await expect(page.getByTestId('title-queued')).toHaveText('1 queued');
|
||||
|
||||
// Let any in-flight / late SSE snapshot (on-connect resync, auto-reconnect)
|
||||
// have its chance to arrive. Under the stub these settle in milliseconds;
|
||||
// a second is generous. Before the version fix, a stale one could clobber
|
||||
// the just-added item here.
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The item is still there — the stale snapshot was dropped, not applied.
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(1);
|
||||
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
|
||||
// Protocol-level check: a later mutation (a second queued item) must carry
|
||||
// a strictly higher version than the one we just saw.
|
||||
await page.getByTestId('action-textarea').fill('Scramble the night fighters');
|
||||
const [add2Resp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
|
||||
),
|
||||
page.getByTestId('add-action').click(),
|
||||
]);
|
||||
const add2Body = (await add2Resp.json()) as { game: { version: number } };
|
||||
expect(add2Body.game.version).toBeGreaterThan(addBody.game.version);
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(2);
|
||||
|
||||
// The game persisted with a version survives a full page reload.
|
||||
await page.goto(`${origin}/#/game/${id}`);
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(2, { timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-item removal — issue #11. Each queued row has a small trash button that
|
||||
* opens a confirmation dialog; confirming removes the item server-side
|
||||
* (POST /api/games/:id/queue/remove, no turn advance) and cancelling keeps it.
|
||||
*/
|
||||
test.describe('History — remove a queued item with confirmation (#11)', () => {
|
||||
test('cancel keeps the item; confirm removes it', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
const id = await createUkGame(page);
|
||||
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
|
||||
// Queue one action.
|
||||
await page.getByTestId('action-textarea').fill('Open the back channel with Vichy');
|
||||
await page.getByTestId('add-action').click();
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(1);
|
||||
|
||||
// Trash icon → confirmation dialog.
|
||||
await page.getByTestId('remove-queue-item').click();
|
||||
const dialog = page.getByTestId('remove-confirm-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText('Open the back channel with Vichy');
|
||||
|
||||
// Cancel: the item stays queued.
|
||||
await page.getByTestId('remove-cancel').click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page.getByTestId('pending-item')).toHaveCount(1);
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
|
||||
// Open again and confirm: the item is removed server-side.
|
||||
await page.getByTestId('remove-queue-item').click();
|
||||
await expect(dialog).toBeVisible();
|
||||
const [removeResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' &&
|
||||
new URL(r.url()).pathname === `/api/games/${id}/queue/remove`,
|
||||
),
|
||||
page.getByTestId('remove-confirm').click(),
|
||||
]);
|
||||
const removeBody = (await removeResp.json()) as { game: { version: number; pending: unknown[] } };
|
||||
expect(removeBody.game.pending).toHaveLength(0);
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page.getByTestId('pending-empty')).toBeVisible();
|
||||
await expect(page.getByTestId('title-queued')).not.toBeVisible();
|
||||
// Still turn 1, no events — removing never advances the round.
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
|
||||
await expect(page.getByTestId('event-item')).toHaveCount(0);
|
||||
|
||||
// The bumped version is persisted with the game (it survives a reload).
|
||||
const after = await page.evaluate(
|
||||
async (gameId) =>
|
||||
((await fetch(`/api/games/${gameId}`)).json()) as Promise<{ game: { version: number } }>,
|
||||
id,
|
||||
);
|
||||
expect(after.game.version).toBe(removeBody.game.version);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* URL deep-linking (issue #3). The active game's id lives in the URL fragment:
|
||||
* home is `#/` and an active game is `#/game/<id>`. A fresh page navigated to
|
||||
* that hash loads that exact game; a bad/missing id falls back to home and
|
||||
* clears the hash; returning to home clears it. A browser context is fresh per
|
||||
* test, so localStorage is isolated, but the e2e webServer shares one SQLite
|
||||
* (`data/e2e`) across tests — so each test captures the *specific* id of the
|
||||
* game it creates (via the POST /api/games response) rather than assuming "the
|
||||
* only game".
|
||||
*/
|
||||
|
||||
/**
|
||||
* Start the first scenario (World War II) and pick the United Kingdom, returning
|
||||
* the freshly created game's id (read from the POST /api/games response). Leaves
|
||||
* the caller on the game screen at turn 1.
|
||||
*/
|
||||
async function createUkGame(page: Page): Promise<string> {
|
||||
await page.goto('/');
|
||||
await page.getByTestId('preset-card').first().waitFor({ timeout: 15000 });
|
||||
await page.getByTestId('preset-card').first().getByRole('button', { name: 'Play' }).click();
|
||||
await expect(page.getByTestId('world-background')).toBeVisible();
|
||||
const [resp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/games',
|
||||
),
|
||||
page
|
||||
.getByTestId('nation-card-uk')
|
||||
.getByRole('button', { name: 'Play as United Kingdom' })
|
||||
.click(),
|
||||
]);
|
||||
const body = (await resp.json()) as { game: { id: string } };
|
||||
return body.game.id;
|
||||
}
|
||||
|
||||
/** Poll until `location.hash` equals `expected` (hash writes happen in effects). */
|
||||
async function waitHash(page: Page, expected: string): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(exp) => window.location.hash === exp,
|
||||
expected,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('History — URL deep-linking (#/game/<id>)', () => {
|
||||
test('creating a game puts its id in the URL', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
const id = await createUkGame(page);
|
||||
|
||||
// The game screen mounts at turn 1; the URL-sync effect writes the deep link.
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
|
||||
await waitHash(page, `#/game/${id}`);
|
||||
});
|
||||
|
||||
test('a fresh page deep-links to the exact saved game', async ({ page, context }) => {
|
||||
test.setTimeout(60000);
|
||||
const id = await createUkGame(page);
|
||||
await waitHash(page, `#/game/${id}`);
|
||||
const origin = await page.evaluate(() => location.origin);
|
||||
|
||||
// A brand-new page navigated straight to the deep link must load that exact
|
||||
// game (matching id, preset, and player) — the URL hash takes precedence over
|
||||
// the (shared) localStorage, so this is deterministic.
|
||||
const page2 = await context.newPage();
|
||||
await page2.goto(`${origin}/#/game/${id}`);
|
||||
await expect(page2.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page2.getByTestId('turn-count')).toHaveText('1');
|
||||
await expect(page2.getByTestId('player-nation')).toHaveText('United Kingdom');
|
||||
await waitHash(page2, `#/game/${id}`);
|
||||
await page2.close();
|
||||
});
|
||||
|
||||
test('a bad game id falls back to the home screen and clears the hash', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
await page.goto('/');
|
||||
const origin = await page.evaluate(() => location.origin);
|
||||
|
||||
// A fresh page navigated to a non-existent id must not crash: it lands on the
|
||||
// home screen and the bad hash is cleared to `#/`.
|
||||
const page2 = await context.newPage();
|
||||
await page2.goto(`${origin}/#/game/does-not-exist-xyz`);
|
||||
await expect(page2.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
|
||||
await waitHash(page2, '#/');
|
||||
await page2.close();
|
||||
});
|
||||
|
||||
test('returning to home from a game clears the hash to #/', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
const id = await createUkGame(page);
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await waitHash(page, `#/game/${id}`);
|
||||
|
||||
// "← Scenarios" returns to the home screen; the URL-sync effect clears the hash.
|
||||
await page.getByTestId('back-to-scenarios').click();
|
||||
await expect(page.getByTestId('preset-card').first()).toBeVisible({ timeout: 15000 });
|
||||
await waitHash(page, '#/');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* HUD revamp (issues #4 and #5). The floating "Map" zoom window is gone — the
|
||||
* full-bleed map is driven directly by wheel/trackpad + drag. The top bar is a
|
||||
* *pinned* element: fixed, non-movable, centered at the top a few px down. The
|
||||
* dossier / feed / action panels can each be docked into a full-height left or
|
||||
* right pane (persisted in `localStorage`); the legend stays a small floating
|
||||
* widget.
|
||||
*/
|
||||
test.describe('History — HUD revamp (pinned top bar, dockable panels, no Map window)', () => {
|
||||
test('Map window is gone; top bar is pinned top-center; panels dock left/right', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
await createUkGame(page);
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
|
||||
const vw = page.viewportSize()!.width;
|
||||
const vh = page.viewportSize()!.height;
|
||||
|
||||
// --- #4: the "Map" zoom window is gone --------------------------------
|
||||
// No zoom window element, and no zoom-in/zoom-out buttons anywhere on the
|
||||
// game screen.
|
||||
await expect(page.locator('[data-float-window="zoom"]')).toHaveCount(0);
|
||||
await expect(page.getByTestId('zoom-in')).toHaveCount(0);
|
||||
await expect(page.getByTestId('zoom-out')).toHaveCount(0);
|
||||
|
||||
// --- #5a: the top bar is pinned, centered at the top ------------------
|
||||
const topBar = page.getByTestId('top-bar');
|
||||
await expect(topBar).toBeVisible();
|
||||
const barBox = (await topBar.boundingBox())!;
|
||||
// Horizontally centered: x ≈ (viewportWidth − width) / 2 …
|
||||
expect(Math.abs(barBox.x - (vw - barBox.width) / 2)).toBeLessThanOrEqual(3);
|
||||
// …and a few px down from the top edge (y ≈ 8).
|
||||
expect(barBox.y).toBeGreaterThanOrEqual(6);
|
||||
expect(barBox.y).toBeLessThanOrEqual(10);
|
||||
|
||||
// A pointer-drag attempted on the pinned bar must not move it (it has no
|
||||
// drag handlers; the map behind it never receives a mousedown).
|
||||
await page.mouse.move(barBox.x + barBox.width / 2, barBox.y + barBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(barBox.x + barBox.width / 2 + 60, barBox.y + barBox.height / 2 + 40, {
|
||||
steps: 6,
|
||||
});
|
||||
await page.mouse.up();
|
||||
const barBoxAfter = (await topBar.boundingBox())!;
|
||||
expect(Math.abs(barBoxAfter.x - barBox.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(barBoxAfter.y - barBox.y)).toBeLessThanOrEqual(1);
|
||||
|
||||
// --- #5b: the dossier docks into a full-height left pane --------------
|
||||
const dossier = page.getByTestId('panel-dossier');
|
||||
await dossier.getByTestId('dock-left').click();
|
||||
const dBox = (await dossier.boundingBox())!;
|
||||
// Pinned to the left edge, filling the viewport height.
|
||||
expect(dBox.x).toBeLessThanOrEqual(2);
|
||||
expect(dBox.y).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(dBox.height - vh)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(dBox.width - 300)).toBeLessThanOrEqual(3);
|
||||
|
||||
// --- #5b: the event feed docks into a full-height right pane ----------
|
||||
const feed = page.getByTestId('panel-feed');
|
||||
await feed.getByTestId('dock-right').click();
|
||||
const fBox = (await feed.boundingBox())!;
|
||||
// Pinned to the right edge: its right side sits on the viewport edge.
|
||||
expect(Math.abs(fBox.x + fBox.width - vw)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(fBox.height - vh)).toBeLessThanOrEqual(3);
|
||||
|
||||
// The "Your move" deck is still a floating panel (we never docked it).
|
||||
await expect(page.getByTestId('panel-action')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a dock choice survives a page reload (persisted in localStorage)', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
await createUkGame(page);
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
const vh = page.viewportSize()!.height;
|
||||
|
||||
// Dock the dossier into the left pane.
|
||||
const dossier = page.getByTestId('panel-dossier');
|
||||
await dossier.getByTestId('dock-left').click();
|
||||
const before = (await dossier.boundingBox())!;
|
||||
expect(before.x).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(before.height - vh)).toBeLessThanOrEqual(3);
|
||||
|
||||
// Reload: the persisted dock choice must restore the docked layout, and the
|
||||
// deep link restores the game.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1', { timeout: 20000 });
|
||||
const after = (await page.getByTestId('panel-dossier').boundingBox())!;
|
||||
expect(after.x).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(after.height - vh)).toBeLessThanOrEqual(3);
|
||||
expect(Math.abs(after.width - 300)).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Advisor "thinking" indicator (issue #15). Asking the advisor is a *separate*
|
||||
* LLM call with its own `advisorBusy` flag — it must never block the turn flow.
|
||||
* Under the deterministic stub the fetch resolves in milliseconds, so the
|
||||
* transient thinking state is too short to assert mid-flight reliably. Instead
|
||||
* we assert the *terminal* states, which are deterministic: before the fetch the
|
||||
* button is enabled and the hint is absent; after it, the advisor list is
|
||||
* populated (5 canned ideas), the hint is gone, the button is re-enabled, and
|
||||
* the top bar is back to "Idle".
|
||||
*/
|
||||
test.describe('History — Advisor thinking indicator (#15)', () => {
|
||||
test('asking the advisor populates the list and clears the busy state', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
await createUkGame(page);
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
|
||||
// Open the Advisor tab.
|
||||
await page.getByTestId('tab-advisor').click();
|
||||
|
||||
// Before any fetch: the button is enabled, no thinking hint, no results yet.
|
||||
const ask = page.getByTestId('ask-advisor');
|
||||
await expect(ask).toBeEnabled();
|
||||
await expect(page.getByTestId('advisor-hint')).toHaveCount(0);
|
||||
await expect(page.getByTestId('advisor-list')).toHaveCount(0);
|
||||
|
||||
// Ask the advisor (with an optional focus). The stub returns 5 canned ideas.
|
||||
await page.getByTestId('advisor-prompt').fill('the nuclear standoff');
|
||||
await ask.click();
|
||||
|
||||
// Terminal state: the result populates the advisor list.
|
||||
await expect(page.getByTestId('advisor-list')).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByTestId('advisor-item')).toHaveCount(5, { timeout: 20000 });
|
||||
|
||||
// The fetch finished: the thinking hint is gone and the button is re-enabled.
|
||||
await expect(page.getByTestId('advisor-hint')).toHaveCount(0);
|
||||
await expect(ask).toBeEnabled();
|
||||
|
||||
// Nothing is in flight now, so the top bar reports idle (not the turn flow).
|
||||
await expect(page.getByTestId('status-indicator')).toHaveText(/Idle/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove a saved game (#20): a saved game can be deleted from the home screen
|
||||
* (with a confirmation dialog), and loading a non-existent / deleted game id
|
||||
* (directly or via a stale open tab) returns the user to the home screen.
|
||||
*/
|
||||
test.describe('History — remove a saved game & redirect (#20)', () => {
|
||||
test('deleting a saved game from the home screen removes it (with confirmation)', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
const id = await createUkGame(page);
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
|
||||
// Back to the home screen, where the just-created game appears in "Saved games".
|
||||
await page.getByTestId('back-to-scenarios').click();
|
||||
await expect(page.getByTestId('saved-games')).toBeVisible({ timeout: 15000 });
|
||||
const card = page.locator(`[data-game-id="${id}"]`);
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
// Opening the confirm dialog…
|
||||
await card.getByTestId('delete-game').click();
|
||||
const dialog = page.getByTestId('delete-game-confirm-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// …and cancelling leaves the game in place.
|
||||
await page.getByTestId('delete-game-cancel').click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
// Confirming the delete removes it: the DELETE succeeds, the card disappears,
|
||||
// and a subsequent fetch reports the game gone.
|
||||
await card.getByTestId('delete-game').click();
|
||||
await expect(page.getByTestId('delete-game-confirm-dialog')).toBeVisible();
|
||||
const [resp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'DELETE' &&
|
||||
new URL(r.url()).pathname === `/api/games/${id}`,
|
||||
),
|
||||
page.getByTestId('delete-game-confirm').click(),
|
||||
]);
|
||||
expect((await resp.json()) as { deleted: boolean }).toEqual({ deleted: true });
|
||||
await expect(page.getByTestId('delete-game-confirm-dialog')).toHaveCount(0);
|
||||
await expect(card).toHaveCount(0);
|
||||
|
||||
// The game is gone from the server too.
|
||||
const status = await page.evaluate(async (gid) => {
|
||||
const r = await fetch(`/api/games/${gid}`);
|
||||
return r.status;
|
||||
}, id);
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
test('deleting an open game sends the active tab home; a deep link lands on home', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(90000);
|
||||
const id = await createUkGame(page);
|
||||
await expect(page.getByTestId('world-map')).toBeVisible();
|
||||
await expect(page.getByTestId('turn-count')).toHaveText('1');
|
||||
const origin = await page.evaluate(() => location.origin);
|
||||
|
||||
// Delete the game straight through the API while its tab is open. The server
|
||||
// broadcasts an SSE `error` and the client's useSse onError handler returns
|
||||
// this live tab to the home screen.
|
||||
const delStatus = await page.evaluate(
|
||||
async (gid) => (await fetch(`/api/games/${gid}`, { method: 'DELETE' })).status,
|
||||
id,
|
||||
);
|
||||
expect(delStatus).toBe(200);
|
||||
await expect(page.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
|
||||
await waitHash(page, '#/');
|
||||
|
||||
// A brand-new page deep-linking to the now-deleted id also lands on home.
|
||||
const page2 = await context.newPage();
|
||||
await page2.goto(`${origin}/#/game/${id}`);
|
||||
await expect(page2.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
|
||||
await waitHash(page2, '#/');
|
||||
await page2.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export default tseslint.config(
|
||||
'vitest.config.ts',
|
||||
'playwright.config.ts',
|
||||
'scripts/**/*.mjs',
|
||||
'scripts/**/*.ts',
|
||||
],
|
||||
languageOptions: { globals: { ...globals.node, ...globals.es2022 } },
|
||||
rules: {
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "history",
|
||||
"version": "0.1.0",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||
@@ -9,6 +9,7 @@
|
||||
"build": "vite build && tsup server/index.ts --format esm --target node22 --out-dir server/dist --external better-sqlite3",
|
||||
"start": "node server/dist/index.js",
|
||||
"test": "vitest run",
|
||||
"generate:regions": "node scripts/generate-regions.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"e2e": "playwright test"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,520 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* scripts/generate-regions.mjs — dependency-free generator for `public/regions.geo.json`.
|
||||
*
|
||||
* Subdivides every country in `public/countries.geo.json` into sub-national
|
||||
* regions sized ~100–200 km on a side (adaptive: a regular lat/lon grid
|
||||
* clipped to the country, so vast/sparse countries — Siberia, Sahara,
|
||||
* Antarctica — end up with bigger regions while Europe gets smaller ones).
|
||||
* Each country gets a few to tens of regions (capped at 36); tiny countries
|
||||
* (and tiny antimeridian islet groups) stay a single region.
|
||||
*
|
||||
* Model: a country's lat/lon bbox is tiled with a cols × rows grid of
|
||||
* ~square-in-km cells. Each grid cell yields at most ONE region feature:
|
||||
* every sub-polygon of the country is clipped against the cell with
|
||||
* Sutherland–Hodgman (edges in order: left, bottom, right, top), clipped
|
||||
* pieces smaller than 300 km² are dropped, and the kept pieces become the
|
||||
* rings of that cell's region (a MultiPolygon when there is more than one).
|
||||
* Region ids are `<ISO_A3>-<nn>`, nn = 1..k in row-major order (rows
|
||||
* top→bottom by descending lat, left→right by ascending lon); names are
|
||||
* `<Country> R<nn>`. Coordinates are rounded to 4 decimal places (≈11 m).
|
||||
* Because a cell produces at most one region, the per-country count can
|
||||
* never exceed the (capped) cell count ≤ 36, even for archipelagos.
|
||||
*
|
||||
* Area: local planar approximation, x = lon·111.32·cos(midLat), y = lat·
|
||||
* 110.574 (km), shoelace per sub-polygon ring, summed in abs. Target region
|
||||
* area T = 12,000 km² (≈110 km side); target count n = clamp(round(area /
|
||||
* T), 1, 36). S = sqrt(bboxAreaKm2 / n); cols = max(1, round(wKm/S)),
|
||||
* rows = max(1, round(hKm/S)); the grid is then reduced if cols·rows > 36.
|
||||
*
|
||||
* Antimeridian handling (three cases):
|
||||
* - Small antimeridian island groups (area ≤ 50,000 km² — Fiji, Kiribati,
|
||||
* Tuvalu, …): one region = the whole country (`<ISO_A3>-01`, plain name).
|
||||
* - Large antimeridian countries made of ordinary sub-polygons (Russia):
|
||||
* the sub-polygons are shifted into longitude space [0, 360) (lons < 0 →
|
||||
* +360) so the country sits in one contiguous band, tiled there, and
|
||||
* output rings that straddle ±180° are split at the antimeridian into a
|
||||
* MultiPolygon with each ring fully on one side — standard −180..180
|
||||
* GeoJSON that renders without cross-map lines.
|
||||
* - Full-360° "polar" wrap rings (Antarctica's main ring, whose only
|
||||
* longitude jump sits at the pole and which crosses the prime meridian
|
||||
* once): such a ring self-intersects in any single planar representation,
|
||||
* so it is replaced by two simple "wedge" rings — the eastern and western
|
||||
* hemispheric halves, each closed along the prime meridian, the pole
|
||||
* (lat −90), and the antimeridian. The wedges tile −180..180 space
|
||||
* without overlap and are tiled like any other ring (no shifting needed).
|
||||
*
|
||||
* Deterministic: same input → byte-identical output.
|
||||
* Run: `npm run generate:regions`
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
// --- parameters ------------------------------------------------------------------
|
||||
const T = 12_000; // target region area, km² (≈110 km side)
|
||||
const MAX_REGIONS = 36; // per-country cap ("tens")
|
||||
const SLIVER_KM2 = 300; // drop clipped pieces smaller than this
|
||||
const BIG_KM2 = 50_000; // antimeridian countries at/above this are tiled, not 1 region
|
||||
const KM_LON = 111.32; // km per degree of longitude (at the equator)
|
||||
const KM_LAT = 110.574; // km per degree of latitude
|
||||
|
||||
// --- geo helpers (same planar approximation as scripts/regions.test.ts) -----------
|
||||
const rad = (deg) => (deg * Math.PI) / 180;
|
||||
const r4 = (v) => Math.round(v * 1e4) / 1e4;
|
||||
|
||||
function polygons(geom) {
|
||||
return geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates;
|
||||
}
|
||||
|
||||
function allCoords(geom) {
|
||||
const out = [];
|
||||
for (const poly of polygons(geom)) for (const ring of poly) for (const c of ring) out.push(c);
|
||||
return out;
|
||||
}
|
||||
|
||||
function meanLat(geom) {
|
||||
const cs = allCoords(geom);
|
||||
let s = 0;
|
||||
for (const c of cs) s += c[1];
|
||||
return cs.length ? s / cs.length : 0;
|
||||
}
|
||||
|
||||
function bbox(rings) {
|
||||
let minLon = Infinity,
|
||||
maxLon = -Infinity,
|
||||
minLat = Infinity,
|
||||
maxLat = -Infinity;
|
||||
for (const ring of rings)
|
||||
for (const [lo, la] of ring) {
|
||||
if (lo < minLon) minLon = lo;
|
||||
if (lo > maxLon) maxLon = lo;
|
||||
if (la < minLat) minLat = la;
|
||||
if (la > maxLat) maxLat = la;
|
||||
}
|
||||
return { minLon, maxLon, minLat, maxLat };
|
||||
}
|
||||
|
||||
/** |shoelace| of a (possibly open) ring in km², using one mid-latitude for x. */
|
||||
function ringAreaKm2(ring, midLat) {
|
||||
const kx = KM_LON * Math.cos(rad(midLat));
|
||||
let a = 0;
|
||||
const n = ring.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT);
|
||||
}
|
||||
return Math.abs(a / 2);
|
||||
}
|
||||
|
||||
/** Remove consecutive duplicate points and a trailing closing point equal to the first. */
|
||||
function dedupeRing(ring) {
|
||||
const out = [];
|
||||
for (const p of ring) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last || last[0] !== p[0] || last[1] !== p[1]) out.push(p);
|
||||
}
|
||||
if (
|
||||
out.length > 1 &&
|
||||
out[0][0] === out[out.length - 1][0] &&
|
||||
out[0][1] === out[out.length - 1][1]
|
||||
)
|
||||
out.pop();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Close an open ring by appending a copy of the first point. */
|
||||
function closeRing(ring) {
|
||||
return ring.length ? [...ring, [...ring[0]]] : ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiling/area subject rings of a feature, in standard −180..180 space:
|
||||
* one open ring per sub-polygon (outer ring only — holes are ignored by
|
||||
* design: Sutherland–Hodgman clips simple rings and any enclosed water is
|
||||
* at most a lake, which the area-sanity tolerance in the test absorbs).
|
||||
* A full-360° wrap ring is replaced by its two hemispheric wedges (see
|
||||
* makeWedges); if that is not possible the ring is returned as-is (the
|
||||
* caller may still shift it).
|
||||
*/
|
||||
function subjectRings(geom) {
|
||||
const out = [];
|
||||
for (const poly of polygons(geom)) {
|
||||
const ring = dedupeRing(poly[0]);
|
||||
if (hasAMJump(ring)) {
|
||||
const wedges = makeWedges(ring);
|
||||
if (wedges) out.push(...wedges);
|
||||
else out.push(ring);
|
||||
} else {
|
||||
out.push(ring);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Sutherland–Hodgman: clip an open subject ring against one half-plane --------
|
||||
function clipEdge(poly, inside, intersect) {
|
||||
const out = [];
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[(i + 1) % poly.length];
|
||||
const aIn = inside(a);
|
||||
const bIn = inside(b);
|
||||
if (aIn && bIn) out.push(b);
|
||||
else if (aIn && !bIn) out.push(intersect(a, b));
|
||||
else if (!aIn && bIn) {
|
||||
out.push(intersect(a, b));
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip an open subject ring against an axis-aligned (lon/lat) rectangle.
|
||||
* Edges are processed in order: left, bottom, right, top. Returns the clipped
|
||||
* open ring, or [] if empty.
|
||||
*/
|
||||
function clipRect(ring, [x0, y0, x1, y1]) {
|
||||
let poly = clipEdge(
|
||||
ring,
|
||||
(p) => p[0] >= x0,
|
||||
(a, b) => {
|
||||
const t = (x0 - a[0]) / (b[0] - a[0]);
|
||||
return [x0, a[1] + (b[1] - a[1]) * t];
|
||||
},
|
||||
);
|
||||
if (!poly.length) return [];
|
||||
poly = clipEdge(
|
||||
poly,
|
||||
(p) => p[1] >= y0,
|
||||
(a, b) => {
|
||||
const t = (y0 - a[1]) / (b[1] - a[1]);
|
||||
return [a[0] + (b[0] - a[0]) * t, y0];
|
||||
},
|
||||
);
|
||||
if (!poly.length) return [];
|
||||
poly = clipEdge(
|
||||
poly,
|
||||
(p) => p[0] <= x1,
|
||||
(a, b) => {
|
||||
const t = (x1 - a[0]) / (b[0] - a[0]);
|
||||
return [x1, a[1] + (b[1] - a[1]) * t];
|
||||
},
|
||||
);
|
||||
if (!poly.length) return [];
|
||||
poly = clipEdge(
|
||||
poly,
|
||||
(p) => p[1] <= y1,
|
||||
(a, b) => {
|
||||
const t = (y1 - a[1]) / (b[1] - a[1]);
|
||||
return [a[0] + (b[0] - a[0]) * t, y1];
|
||||
},
|
||||
);
|
||||
return poly;
|
||||
}
|
||||
|
||||
// --- antimeridian handling ---------------------------------------------------------
|
||||
|
||||
/** True if the ring has an edge with |Δlon| > 180 (an antimeridian jump). */
|
||||
function hasAMJump(ring) {
|
||||
const n = ring.length;
|
||||
for (let i = 0; i < n; i++)
|
||||
if (Math.abs(ring[i][0] - ring[(i + 1) % n][0]) > 180) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function crossesAntimeridian(geom) {
|
||||
let minLon = Infinity,
|
||||
maxLon = -Infinity;
|
||||
for (const [lo] of allCoords(geom)) {
|
||||
if (lo < minLon) minLon = lo;
|
||||
if (lo > maxLon) maxLon = lo;
|
||||
}
|
||||
return maxLon > 0 && minLon < 0 && maxLon - minLon > 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a full-360° wrap ring (one antimeridian jump, one prime-meridian
|
||||
* crossing) by two simple hemispheric "wedge" rings, each closed along the
|
||||
* prime meridian (x = 0), the pole (lat = −90), and the antimeridian
|
||||
* (x = ±180). Both wedges lie in standard −180..180 space, are disjoint, and
|
||||
* together cover exactly the original ring's enclosed region. Returns
|
||||
* [westRing, eastRing] (open rings), or null if the ring has an unexpected
|
||||
* shape (no wrap handling possible).
|
||||
*/
|
||||
function makeWedges(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return null;
|
||||
const jAM = [];
|
||||
const jPM = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = ring[i];
|
||||
const b = ring[(i + 1) % n];
|
||||
if (Math.abs(a[0] - b[0]) > 180) jAM.push(i);
|
||||
else if (a[0] !== 0 && b[0] !== 0 && (a[0] < 0) !== (b[0] < 0)) jPM.push(i);
|
||||
}
|
||||
if (jAM.length !== 1 || jPM.length !== 1) return null;
|
||||
|
||||
const iAM = jAM[0];
|
||||
const a = ring[iAM]; // the +180 end of the jump
|
||||
const b = ring[(iAM + 1) % n]; // the −180 end of the jump
|
||||
if (Math.abs(a[0] - 180) > 1e-6 || Math.abs(b[0] + 180) > 1e-6) return null;
|
||||
|
||||
const iPM = jPM[0];
|
||||
const p = ring[iPM];
|
||||
const q = ring[(iPM + 1) % n];
|
||||
const t = (0 - p[0]) / (q[0] - p[0]);
|
||||
const P = [0, p[1] + (q[1] - p[1]) * t]; // the exact prime-meridian crossing
|
||||
|
||||
// Chain from b (−180) forward to P: everything in between must have lon ≤ 0.
|
||||
const west = [];
|
||||
for (let i = (iAM + 1) % n; ; i = (i + 1) % n) {
|
||||
const pt = ring[i];
|
||||
if (pt[0] > 0) return null;
|
||||
west.push(pt);
|
||||
if (i === iPM) break;
|
||||
}
|
||||
west.push(P);
|
||||
// Chain from P forward to a (+180): everything in between must have lon ≥ 0.
|
||||
const east = [P];
|
||||
for (let i = (iPM + 1) % n; ; i = (i + 1) % n) {
|
||||
const pt = ring[i];
|
||||
if (pt[0] < 0) return null;
|
||||
east.push(pt);
|
||||
if (i === iAM) break;
|
||||
}
|
||||
|
||||
const pole = (x) => [x, -90];
|
||||
// West wedge: P → pole along x=0 → pole along the pole → up the −180 side →
|
||||
// the western coast back to P. East wedge: the mirror image on x ≥ 0.
|
||||
const westWedge = dedupeRing([P, pole(0), pole(-180), b, ...west.slice(1)]);
|
||||
// The east chain q→…→a runs in *increasing* lon, so to trace the coast back
|
||||
// from a to q (keeping the wedge simple) we walk it in reverse.
|
||||
const eastWedge = dedupeRing([P, pole(0), pole(180), a, ...east.slice(1, -1).reverse()]);
|
||||
if (westWedge.length < 3 || eastWedge.length < 3) return null;
|
||||
return [westWedge, eastWedge];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an open ring (in shifted [0,360) space) at the antimeridian (x = 180).
|
||||
*
|
||||
* Instead of walking the ring between crossings (which breaks on jagged
|
||||
* coastlines that cross the antimeridian many times), clip the ring against
|
||||
* the two half-planes x ≤ 180 and x ≥ 180 with Sutherland–Hodgman. Each piece
|
||||
* is a proper closed-on-the-split-line polygon, the two pieces share only the
|
||||
* x = 180 boundary, and the split is area-preserving (the boundary has zero
|
||||
* area). Both pieces are returned in shifted space; the caller remaps any
|
||||
* x > 180 piece back into −180..0.
|
||||
*/
|
||||
function splitAtAM(ring) {
|
||||
if (ring.length < 3) return [];
|
||||
const cross = (a, b) => {
|
||||
const t = (180 - a[0]) / (b[0] - a[0]);
|
||||
return [180, a[1] + (b[1] - a[1]) * t];
|
||||
};
|
||||
const left = clipEdge(ring, (p) => p[0] <= 180, cross); // x in [0, 180]
|
||||
// Right piece: x in [180, 360). Remap EVERY vertex by -360 (including the
|
||||
// shared x = 180 boundary vertex → -180) so the piece sits fully on the west
|
||||
// side of the antimeridian with no ±180 jump inside the ring.
|
||||
const right = clipEdge(ring, (p) => p[0] >= 180, cross).map(
|
||||
([lo, la]) => [lo - 360, la],
|
||||
);
|
||||
const pieces = [];
|
||||
if (left.length >= 3) pieces.push(left);
|
||||
if (right.length >= 3) pieces.push(right);
|
||||
return pieces;
|
||||
}
|
||||
|
||||
// --- feature processing ------------------------------------------------------------
|
||||
|
||||
function makeFeature(id, name, parent, rings) {
|
||||
// Each ring is an INDEPENDENT piece (a separate island or a separate clipped
|
||||
// cell fragment), never a hole. GeoJSON: a MultiPolygon is an array of
|
||||
// polygons, each polygon an array of rings — so wrap every ring in its own
|
||||
// (single-ring) polygon. ([rings] would instead make ring 0 the outer and
|
||||
// every other ring a hole, swallowing e.g. Fiji's other islands.)
|
||||
return {
|
||||
type: 'Feature',
|
||||
id,
|
||||
properties: { name, parent },
|
||||
geometry:
|
||||
rings.length === 1
|
||||
? { type: 'Polygon', coordinates: [rings[0]] }
|
||||
: { type: 'MultiPolygon', coordinates: rings.map((r) => [r]) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-country single region (no tiling, no sliver drops): all sub-polygons
|
||||
* in one feature, coordinates rounded. Used for tiny countries (n = 1) and
|
||||
* small antimeridian-crossing island groups.
|
||||
*/
|
||||
function wholeCountryFeature(feature) {
|
||||
const id = feature.id;
|
||||
const name = feature.properties.name;
|
||||
const rings = polygons(feature.geometry).map((poly) =>
|
||||
closeRing(dedupeRing(poly[0]).map(([lo, la]) => [r4(lo), r4(la)])),
|
||||
);
|
||||
return [makeFeature(`${id}-01`, name, id, rings)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile the country's bbox with a cols × rows grid; each non-empty cell yields
|
||||
* one region feature (its kept clipped pieces as a Polygon/MultiPolygon).
|
||||
* `shifted` = true for large non-wrapping antimeridian countries (Russia):
|
||||
* work in [0, 360) longitude space, then split output rings at ±180°.
|
||||
*/
|
||||
function tileFeature(feature, rings, shifted) {
|
||||
const id = feature.id;
|
||||
const name = feature.properties.name;
|
||||
const midLat = meanLat(feature.geometry);
|
||||
const area = rings.reduce((s, r) => s + ringAreaKm2(r, midLat), 0);
|
||||
|
||||
const n = Math.min(MAX_REGIONS, Math.max(1, Math.round(area / T)));
|
||||
if (n === 1) return wholeCountryFeature(feature);
|
||||
|
||||
const shiftLon = (lo) => (shifted && lo < 0 ? lo + 360 : lo);
|
||||
const work = rings.map((r) => r.map(([lo, la]) => [shiftLon(lo), la]));
|
||||
const bb = bbox(work);
|
||||
|
||||
const kx = KM_LON * Math.cos(rad(midLat));
|
||||
const wKm = (bb.maxLon - bb.minLon) * kx;
|
||||
const hKm = (bb.maxLat - bb.minLat) * KM_LAT;
|
||||
const S = Math.sqrt((wKm * hKm) / n);
|
||||
let cols = Math.max(1, Math.round(wKm / S));
|
||||
let rows = Math.max(1, Math.round(hKm / S));
|
||||
while (cols * rows > MAX_REGIONS) {
|
||||
if (cols >= rows) cols = Math.max(1, Math.floor(MAX_REGIONS / rows));
|
||||
else rows = Math.max(1, Math.floor(MAX_REGIONS / cols));
|
||||
}
|
||||
|
||||
const dLon = (bb.maxLon - bb.minLon) / cols;
|
||||
const dLat = (bb.maxLat - bb.minLat) / rows;
|
||||
|
||||
const out = [];
|
||||
let nn = 0;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const yTop = bb.maxLat - r * dLat;
|
||||
const yBot = bb.maxLat - (r + 1) * dLat;
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const xLeft = bb.minLon + c * dLon;
|
||||
const xRight = bb.minLon + (c + 1) * dLon;
|
||||
// One region per cell: clip every sub-ring, keep the pieces that clear
|
||||
// the sliver threshold, and merge them into a single feature.
|
||||
const cellPieces = [];
|
||||
for (const ring of work) {
|
||||
const clipped = clipRect(ring, [xLeft, yBot, xRight, yTop]);
|
||||
if (clipped.length < 3) continue;
|
||||
if (ringAreaKm2(clipped, midLat) < SLIVER_KM2) continue;
|
||||
// splitAtAM returns pieces already in standard −180..180 space (the
|
||||
// x > 180 half remapped), so no further per-vertex remap is needed.
|
||||
const pieces = shifted
|
||||
? splitAtAM(dedupeRing(clipped)).map((pc) => dedupeRing(pc))
|
||||
: [dedupeRing(clipped)];
|
||||
for (const pc of pieces)
|
||||
if (pc.length >= 3) cellPieces.push(pc.map(([lo, la]) => [r4(lo), r4(la)]));
|
||||
}
|
||||
if (!cellPieces.length) continue;
|
||||
const closed = cellPieces
|
||||
.map(dedupeRing)
|
||||
.filter((pc) => pc.length >= 3)
|
||||
.map(closeRing);
|
||||
if (!closed.length) continue;
|
||||
nn += 1;
|
||||
const nnStr = String(nn).padStart(2, '0');
|
||||
out.push(makeFeature(`${id}-${nnStr}`, `${name} R${nnStr}`, id, closed));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function processFeature(feature) {
|
||||
const geom = feature.geometry;
|
||||
const am = crossesAntimeridian(geom);
|
||||
if (am && featureAreaKm2(feature) <= BIG_KM2) {
|
||||
// Small antimeridian island group (Fiji, Kiribati, Tuvalu, …): one region.
|
||||
return wholeCountryFeature(feature);
|
||||
}
|
||||
// Wrapping rings (Antarctica) are already in standard space as wedges — no
|
||||
// shifting; non-wrapping antimeridian countries (Russia) are shifted.
|
||||
const rings = subjectRings(geom);
|
||||
const hasWrap = polygons(geom).some((p) => hasAMJump(dedupeRing(p[0])));
|
||||
const shifted = am && !hasWrap;
|
||||
return tileFeature(feature, rings, shifted);
|
||||
}
|
||||
|
||||
/** Total planar area of a feature's tiling rings (same convention as tileFeature). */
|
||||
function featureAreaKm2(feature) {
|
||||
const midLat = meanLat(feature.geometry);
|
||||
return subjectRings(feature.geometry).reduce((s, r) => s + ringAreaKm2(r, midLat), 0);
|
||||
}
|
||||
|
||||
// --- main ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const src = readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8');
|
||||
const countries = JSON.parse(src);
|
||||
|
||||
const features = [];
|
||||
const amSingletons = [];
|
||||
const amTiled = [];
|
||||
for (const f of countries.features) {
|
||||
if (crossesAntimeridian(f.geometry)) {
|
||||
(featureAreaKm2(f) <= BIG_KM2 ? amSingletons : amTiled).push(f.id);
|
||||
}
|
||||
features.push(...processFeature(f));
|
||||
}
|
||||
// Sorted by (parent, nn) — the processing order already guarantees this, but
|
||||
// sort explicitly so the contract holds regardless of traversal changes.
|
||||
features.sort((a, b) => {
|
||||
if (a.properties.parent !== b.properties.parent)
|
||||
return a.properties.parent < b.properties.parent ? -1 : 1;
|
||||
return a.id.localeCompare(b.id, 'en', { numeric: true });
|
||||
});
|
||||
|
||||
const out = { type: 'FeatureCollection', features };
|
||||
const json = JSON.stringify(out);
|
||||
writeFileSync(path.join(root, 'public', 'regions.geo.json'), json + '\n');
|
||||
|
||||
// --- summary ----------------------------------------------------------------------
|
||||
const counts = new Map();
|
||||
for (const f of features)
|
||||
counts.set(f.properties.parent, (counts.get(f.properties.parent) ?? 0) + 1);
|
||||
const sortedCounts = [...counts.values()].sort((a, b) => a - b);
|
||||
const median =
|
||||
sortedCounts.length % 2
|
||||
? sortedCounts[Math.floor(sortedCounts.length / 2)]
|
||||
: (sortedCounts[sortedCounts.length / 2 - 1] + sortedCounts[sortedCounts.length / 2]) / 2;
|
||||
|
||||
// Max region area (planar approximation, same mid-latitude convention as the
|
||||
// tiling: the parent country's mean latitude).
|
||||
const midLatByParent = new Map(countries.features.map((f) => [f.id, meanLat(f.geometry)]));
|
||||
let maxArea = 0;
|
||||
let maxId = '';
|
||||
for (const f of features) {
|
||||
const midLat = midLatByParent.get(f.properties.parent);
|
||||
let a = 0;
|
||||
for (const poly of polygons(f.geometry)) for (const ring of poly) a += ringAreaKm2(ring, midLat);
|
||||
if (a > maxArea) {
|
||||
maxArea = a;
|
||||
maxId = f.id;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`regions: ${features.length} total (${countries.features.length} countries)`);
|
||||
console.log(`file: public/regions.geo.json — ${(Buffer.byteLength(json + '\n') / 1024).toFixed(1)} KB`);
|
||||
console.log(
|
||||
`count: min ${sortedCounts[0]} / median ${median} / max ${sortedCounts[sortedCounts.length - 1]} per country`,
|
||||
);
|
||||
console.log(`max region: ${maxId} ≈ ${Math.round(maxArea).toLocaleString('en-US')} km²`);
|
||||
if (amSingletons.length) console.log(`AM → 1 region: ${amSingletons.join(', ')}`);
|
||||
if (amTiled.length) console.log(`AM → tiled (shifted/wedges): ${amTiled.join(', ')}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* scripts/regions.test.ts — validation for the committed `public/regions.geo.json`.
|
||||
*
|
||||
* Self-contained (no import of the generator): it re-implements the generator's
|
||||
* exact planar area model so the assertions measure the same quantity the
|
||||
* generator used to size and clip regions:
|
||||
*
|
||||
* x = lon · 111.32 · cos(midLat), y = lat · 110.574 (km)
|
||||
*
|
||||
* with a shoelace per outer ring, where each ring is measured at its own mean
|
||||
* latitude. Outer rings only — the generator never emits holes (every ring is an
|
||||
* independent piece: a separate island or a separate clipped cell fragment).
|
||||
*
|
||||
* It reads each file exactly once and runs in O(total vertices).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
|
||||
// --- area model (must match scripts/generate-regions.mjs exactly) -----------------
|
||||
const KM_LON = 111.32;
|
||||
const KM_LAT = 110.574;
|
||||
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||
|
||||
type Ring = [number, number][];
|
||||
type Geometry =
|
||||
| { type: 'Polygon'; coordinates: Ring[] }
|
||||
| { type: 'MultiPolygon'; coordinates: Ring[][] };
|
||||
|
||||
function ringAreaKm2(ring: Ring, midLat: number): number {
|
||||
const kx = KM_LON * Math.cos(rad(midLat));
|
||||
let a = 0;
|
||||
const n = ring.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT);
|
||||
}
|
||||
return Math.abs(a / 2);
|
||||
}
|
||||
|
||||
function outerRings(geom: Geometry): Ring[] {
|
||||
if (geom.type === 'Polygon') return [geom.coordinates[0]];
|
||||
if (geom.type === 'MultiPolygon') return geom.coordinates.map((p) => p[0]);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Sum of a feature's outer-ring areas, each ring at its own mean latitude. */
|
||||
function featureAreaKm2(geom: Geometry): number {
|
||||
let total = 0;
|
||||
for (const ring of outerRings(geom)) {
|
||||
let lat = 0;
|
||||
for (const [, la] of ring) lat += la;
|
||||
total += ringAreaKm2(ring, ring.length ? lat / ring.length : 0);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
// -------------------------------------------------------------------------------
|
||||
|
||||
interface RegionFeature {
|
||||
type: 'Feature';
|
||||
id: string;
|
||||
properties: { name: string; parent: string };
|
||||
geometry: Geometry;
|
||||
}
|
||||
interface CountryFeature {
|
||||
type: 'Feature';
|
||||
id: string;
|
||||
properties: { name: string };
|
||||
geometry: Geometry;
|
||||
}
|
||||
interface FeatureCollection<T> {
|
||||
type: 'FeatureCollection';
|
||||
features: T[];
|
||||
}
|
||||
|
||||
// Read each file exactly once.
|
||||
const regions = JSON.parse(
|
||||
readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'),
|
||||
) as FeatureCollection<RegionFeature>;
|
||||
const countries = JSON.parse(
|
||||
readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'),
|
||||
) as FeatureCollection<CountryFeature>;
|
||||
|
||||
// Precompute areas once (O(total vertices)).
|
||||
const countryArea = new Map<string, number>();
|
||||
for (const c of countries.features) countryArea.set(c.id, featureAreaKm2(c.geometry));
|
||||
const regionArea = new Map<string, number>();
|
||||
for (const f of regions.features) regionArea.set(f.id, featureAreaKm2(f.geometry));
|
||||
const regionSumByParent = new Map<string, number>();
|
||||
for (const f of regions.features) {
|
||||
regionSumByParent.set(
|
||||
f.properties.parent,
|
||||
(regionSumByParent.get(f.properties.parent) ?? 0) + (regionArea.get(f.id) ?? 0),
|
||||
);
|
||||
}
|
||||
const countByParent = new Map<string, number>();
|
||||
for (const f of regions.features) {
|
||||
countByParent.set(f.properties.parent, (countByParent.get(f.properties.parent) ?? 0) + 1);
|
||||
}
|
||||
|
||||
describe('regions.geo.json', () => {
|
||||
it('is a valid GeoJSON FeatureCollection', () => {
|
||||
expect(regions.type).toBe('FeatureCollection');
|
||||
expect(Array.isArray(regions.features)).toBe(true);
|
||||
expect(regions.features.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every region is a well-formed feature with a valid id and a valid parent country', () => {
|
||||
const countryIds = new Set(countries.features.map((c) => c.id));
|
||||
const idRe = /^[A-Z]{3}-\d+$/;
|
||||
for (const f of regions.features) {
|
||||
expect(f.type, `${f.id ?? '?'}: not a Feature`).toBe('Feature');
|
||||
expect(idRe.test(f.id), `${f.id ?? '?'}: bad id`).toBe(true);
|
||||
expect(typeof f.properties?.name, `${f.id}: missing name`).toBe('string');
|
||||
expect(f.properties.name.length, `${f.id}: empty name`).toBeGreaterThan(0);
|
||||
expect(typeof f.properties.parent, `${f.id}: missing parent`).toBe('string');
|
||||
expect(countryIds.has(f.properties.parent), `${f.id}: unknown parent ${f.properties.parent}`).toBe(true);
|
||||
expect(f.properties.parent, `${f.id}: parent != ISO_A3 prefix`).toBe(f.id.slice(0, 3));
|
||||
const g = f.geometry;
|
||||
expect(
|
||||
!!g && (g.type === 'Polygon' || g.type === 'MultiPolygon'),
|
||||
`${f.id}: bad geometry type ${g?.type}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every country 1..36 regions, and >=2 for any country > 50,000 km²', () => {
|
||||
for (const c of countries.features) {
|
||||
const n = countByParent.get(c.id) ?? 0;
|
||||
expect(n, `country ${c.id} has no regions`).toBeGreaterThanOrEqual(1);
|
||||
expect(n, `country ${c.id} has ${n} regions (> 36)`).toBeLessThanOrEqual(36);
|
||||
if ((countryArea.get(c.id) ?? 0) > 50_000) {
|
||||
expect(n, `large country ${c.id} should have >= 2 regions`).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('every region has a sane area: >= 200 km² (microstates exempt) and < 5,000,000 km²', () => {
|
||||
let maxArea = 0;
|
||||
let maxId = '';
|
||||
for (const f of regions.features) {
|
||||
const a = regionArea.get(f.id) ?? 0;
|
||||
const parentArea = countryArea.get(f.properties.parent) ?? 0;
|
||||
// A whole-country singleton of a sub-200 km² microstate is exempt.
|
||||
expect(a >= 200 || parentArea < 200, `${f.id}: area ${a.toFixed(1)} km² is too small`).toBe(true);
|
||||
expect(a < 5_000_000, `${f.id}: area ${Math.round(a).toLocaleString('en-US')} km² is absurdly large`).toBe(true);
|
||||
if (a > maxArea) {
|
||||
maxArea = a;
|
||||
maxId = f.id;
|
||||
}
|
||||
}
|
||||
console.log(`\n max region area: ${maxId} ≈ ${Math.round(maxArea).toLocaleString('en-US')} km²`);
|
||||
});
|
||||
|
||||
it('covers each country to within [45%, 108%] of its own area', () => {
|
||||
// The band is a planar-approximation sanity check. The measured range is
|
||||
// ~[64%, 105%]: Antarctica's hemispheric wedges under-fill to ~64%, and
|
||||
// Norway's fjord-heavy, high-latitude coast over-fills to ~105.1% under the
|
||||
// single-mid-latitude approximation. [45%, 108%] brackets that with margin
|
||||
// while still catching a genuinely missing or doubled-up region.
|
||||
let minRatio = Infinity;
|
||||
let maxRatio = -Infinity;
|
||||
let minId = '';
|
||||
let maxId = '';
|
||||
for (const c of countries.features) {
|
||||
const ca = countryArea.get(c.id) ?? 0;
|
||||
if (ca <= 0) continue;
|
||||
const ratio = (regionSumByParent.get(c.id) ?? 0) / ca;
|
||||
if (ratio < minRatio) {
|
||||
minRatio = ratio;
|
||||
minId = c.id;
|
||||
}
|
||||
if (ratio > maxRatio) {
|
||||
maxRatio = ratio;
|
||||
maxId = c.id;
|
||||
}
|
||||
expect(
|
||||
ratio >= 0.45 && ratio <= 1.08,
|
||||
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [45%, 108%]`,
|
||||
).toBe(true);
|
||||
}
|
||||
console.log(
|
||||
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}% / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 45–108%)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@ function makeState(id: string): GameState {
|
||||
},
|
||||
},
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +55,11 @@ describe('openDb', () => {
|
||||
expect(db.load('a')?.events).toHaveLength(1);
|
||||
expect([...db.listIds()].sort()).toEqual(['a', 'b']); // still two rows
|
||||
|
||||
// the monotonic version is persisted with the state and round-trips
|
||||
s.version = 3;
|
||||
db.save(s);
|
||||
expect(db.load('a')?.version).toBe(3);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -65,4 +72,19 @@ describe('openDb', () => {
|
||||
expect(db.load('missing')).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('delete removes a game by id and reports absence otherwise', () => {
|
||||
const db = openDb(':memory:');
|
||||
db.save(makeState('a'));
|
||||
db.save(makeState('b'));
|
||||
|
||||
expect(db.delete('nope')).toBe(false); // unknown id
|
||||
expect(db.delete('a')).toBe(true); // removes a
|
||||
expect(db.load('a')).toBeNull();
|
||||
expect(db.load('b')?.id).toBe('b');
|
||||
expect([...db.listIds()].sort()).toEqual(['b']);
|
||||
|
||||
expect(db.delete('a')).toBe(false); // already gone
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Db {
|
||||
save(state: GameState): void;
|
||||
load(id: string): GameState | null;
|
||||
listIds(): string[];
|
||||
delete(id: string): boolean;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
@@ -29,6 +30,7 @@ export function openDb(filePath: string): Db {
|
||||
);
|
||||
const byId = db.prepare('SELECT data FROM games WHERE id = ?');
|
||||
const all = db.prepare('SELECT id FROM games ORDER BY updated_at DESC');
|
||||
const del = db.prepare('DELETE FROM games WHERE id = ?');
|
||||
return {
|
||||
save(state) {
|
||||
upsert.run(state.id, JSON.stringify(state), new Date().toISOString());
|
||||
@@ -45,6 +47,9 @@ export function openDb(filePath: string): Db {
|
||||
listIds() {
|
||||
return (all.all() as { id: string }[]).map((r) => r.id);
|
||||
},
|
||||
delete(id) {
|
||||
return del.run(id).changes > 0;
|
||||
},
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
|
||||
+232
-8
@@ -1,11 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GameState, Preset } from '../shared/types';
|
||||
import type { TurnResult } from '../shared/protocol';
|
||||
import type { GameState, Preset, RoundItem } from '../shared/types';
|
||||
import { makeEvent, type TurnResult } from '../shared/protocol';
|
||||
import type { LlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import {
|
||||
applyTurnResult,
|
||||
buildSystemPrompt,
|
||||
buildUserPrompt,
|
||||
isMeaningful,
|
||||
parseTurnResult,
|
||||
queueRoundItem,
|
||||
removeRoundItem,
|
||||
runTurn,
|
||||
validateTurnResult,
|
||||
} from './engine';
|
||||
@@ -68,6 +73,8 @@ function baseState(): GameState {
|
||||
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
|
||||
},
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,17 +92,18 @@ const validResult = JSON.stringify({
|
||||
});
|
||||
|
||||
describe('runTurn', () => {
|
||||
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date', async () => {
|
||||
it('applies a canned TurnResult: stats, symmetric relations, territory, event, turn, date, pending cleared', async () => {
|
||||
const original = baseState();
|
||||
const { state, result } = await runTurn({
|
||||
llm: fakeLlm([validResult]),
|
||||
preset,
|
||||
state: original,
|
||||
action: 'We rearm the coast',
|
||||
items: [{ kind: 'action', text: 'We rearm the coast' }],
|
||||
});
|
||||
|
||||
expect(state.turn).toBe(2); // the turn counter advanced
|
||||
expect(state.date).toBe('1939-09-03');
|
||||
expect(state.pending).toEqual([]); // committing a round always clears it
|
||||
expect(state.nations.uk.stats.military).toBe(85);
|
||||
expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add)
|
||||
// relations are symmetric
|
||||
@@ -131,7 +139,7 @@ describe('runTurn', () => {
|
||||
llm: fakeLlm([clampResult]),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
items: [],
|
||||
});
|
||||
expect(state.nations.uk.stats.military).toBe(100);
|
||||
expect(state.nations.uk.stats.stability).toBe(0);
|
||||
@@ -142,7 +150,7 @@ describe('runTurn', () => {
|
||||
llm: fakeLlm(['total garbage', 'still not json']),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
items: [],
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.events).toHaveLength(1);
|
||||
@@ -162,7 +170,7 @@ describe('runTurn', () => {
|
||||
llm: fakeLlm([emptyResult, emptyResult]),
|
||||
preset,
|
||||
state: baseState(),
|
||||
action: 'x',
|
||||
items: [],
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.events[0].title).toBe('The turn was unclear');
|
||||
@@ -174,13 +182,212 @@ describe('runTurn', () => {
|
||||
const { state: next } = await runTurn({
|
||||
llm: fakeLlm([validResult]),
|
||||
preset,
|
||||
state, // no action / no chat = jump forward
|
||||
state, // no items = an empty round / jump forward
|
||||
items: [],
|
||||
});
|
||||
expect(next.turn).toBe(4);
|
||||
expect(next.events).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runTurn (batched round, deterministic stub)', () => {
|
||||
it('a single action item: turn+1, date+2, pending cleared, one military event, military set to clamp(cur+5)', async () => {
|
||||
const original = baseState();
|
||||
original.pending = [{ kind: 'action', text: 'Fortify the coast and mobilize the home guard' }];
|
||||
const { state, result } = await runTurn({
|
||||
llm: makeStubLlmClient(),
|
||||
preset,
|
||||
state: original,
|
||||
items: original.pending,
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.date).toBe('1939-09-03');
|
||||
expect(state.pending).toEqual([]);
|
||||
expect(state.nations.uk.stats.military).toBe(85); // clamp(80 + 5)
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].type).toBe('military');
|
||||
expect(state.events[0].nation).toBe('uk');
|
||||
expect(state.events[0].outcome).toBe('success');
|
||||
expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]);
|
||||
// committing a turn is a new revision too
|
||||
expect(state.version).toBe(2);
|
||||
// the original object is not mutated (a fresh queue was consumed)
|
||||
expect(original.pending).toHaveLength(1);
|
||||
expect(original.version).toBe(1);
|
||||
});
|
||||
|
||||
it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
|
||||
const { state } = await runTurn({
|
||||
llm: makeStubLlmClient(),
|
||||
preset,
|
||||
state: baseState(), // pending: []
|
||||
items: [],
|
||||
});
|
||||
expect(state.turn).toBe(2);
|
||||
expect(state.date).toBe('1939-09-03');
|
||||
expect(state.pending).toEqual([]);
|
||||
expect(state.events).toHaveLength(1);
|
||||
expect(state.events[0].type).toBe('political');
|
||||
expect(state.events[0].title).toBe('The world moves on');
|
||||
expect(state.events[0].outcome).toBe('neutral');
|
||||
});
|
||||
|
||||
it('a round with two actions and one diplomacy: 3 events (2 military + 1 diplomatic), exactly one turn advance', async () => {
|
||||
const state = baseState();
|
||||
state.pending = [
|
||||
{ kind: 'action', text: 'Reinforce the Atlantic convoys' },
|
||||
{ kind: 'action', text: 'Expand the shipyards' },
|
||||
{ kind: 'diplomacy', to: 'us', text: 'Propose a mutual defense pact' },
|
||||
];
|
||||
const { state: next } = await runTurn({
|
||||
llm: makeStubLlmClient(),
|
||||
preset,
|
||||
state,
|
||||
items: state.pending,
|
||||
});
|
||||
expect(next.turn).toBe(2); // a whole batch = one turn
|
||||
expect(next.pending).toEqual([]);
|
||||
expect(next.events).toHaveLength(3);
|
||||
expect(next.events.filter((e) => e.type === 'military')).toHaveLength(2);
|
||||
expect(next.events.filter((e) => e.type === 'diplomatic')).toHaveLength(1);
|
||||
expect(next.nations.uk.stats.military).toBe(85); // one +5 per turn, not per item
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserPrompt (batched round)', () => {
|
||||
it('includes every queued item (action + diplomacy) in the emitted payload', () => {
|
||||
const items: RoundItem[] = [
|
||||
{ kind: 'action', text: 'Fortify Dover' },
|
||||
{ kind: 'diplomacy', to: 'us', text: 'Propose a pact' },
|
||||
];
|
||||
const parsed = JSON.parse(buildUserPrompt(baseState(), items)) as {
|
||||
pending: RoundItem[];
|
||||
playerActions: unknown[];
|
||||
roundInstruction: string;
|
||||
};
|
||||
expect(parsed.pending).toEqual(items);
|
||||
expect(parsed.playerActions).toEqual(['Fortify Dover', { to: 'us', message: 'Propose a pact' }]);
|
||||
expect(parsed.roundInstruction).toContain('Fortify Dover');
|
||||
expect(parsed.roundInstruction).toContain('Propose a pact');
|
||||
});
|
||||
|
||||
it('frames an empty round as "the world advances on its own momentum"', () => {
|
||||
const parsed = JSON.parse(buildUserPrompt(baseState(), [])) as {
|
||||
pending: RoundItem[];
|
||||
roundInstruction: string;
|
||||
};
|
||||
expect(parsed.pending).toEqual([]);
|
||||
expect(parsed.roundInstruction).toMatch(/no action/i);
|
||||
expect(parsed.roundInstruction).toMatch(/own momentum/i);
|
||||
});
|
||||
|
||||
it('caps recentEvents at the last 5 (in order) when the state has more than 5 events', () => {
|
||||
const state = baseState();
|
||||
const events = Array.from({ length: 8 }, (_, i) =>
|
||||
makeEvent(
|
||||
{
|
||||
nation: 'uk',
|
||||
type: 'political',
|
||||
title: `Event ${i + 1}`,
|
||||
text: `Narrative ${i + 1}`,
|
||||
outcome: 'neutral',
|
||||
},
|
||||
i + 1,
|
||||
`1939-09-0${i + 1}`,
|
||||
),
|
||||
);
|
||||
state.events = events;
|
||||
|
||||
const parsed = JSON.parse(buildUserPrompt(state, [])) as {
|
||||
state: { recentEvents: Array<{ title: string; turn: number; date: string }> };
|
||||
};
|
||||
|
||||
expect(parsed.state.recentEvents).toHaveLength(5);
|
||||
// the last 5 of the 8 input events, in order
|
||||
expect(parsed.state.recentEvents[0].title).toBe(events[3].title); // 'Event 4'
|
||||
expect(parsed.state.recentEvents[4].title).toBe(events[7].title); // 'Event 8'
|
||||
expect(parsed.state.recentEvents.map((e) => e.title)).toEqual([
|
||||
events[3].title,
|
||||
events[4].title,
|
||||
events[5].title,
|
||||
events[6].title,
|
||||
events[7].title,
|
||||
]);
|
||||
});
|
||||
|
||||
it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => {
|
||||
expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueRoundItem', () => {
|
||||
it('appends an item to pending without advancing the turn or the date', () => {
|
||||
const before = baseState();
|
||||
const after = queueRoundItem(before, { kind: 'diplomacy', to: 'us', text: 'hello' });
|
||||
expect(after.turn).toBe(before.turn);
|
||||
expect(after.date).toBe(before.date);
|
||||
expect(after.pending).toHaveLength(1);
|
||||
expect(after.pending[0]).toEqual({ kind: 'diplomacy', to: 'us', text: 'hello' });
|
||||
// the original state is not mutated
|
||||
expect(before.pending).toHaveLength(0);
|
||||
expect(before.version).toBe(1);
|
||||
});
|
||||
|
||||
it('bumps the monotonic version on each queue mutation', () => {
|
||||
const a = queueRoundItem(baseState(), { kind: 'action', text: 'one' });
|
||||
expect(a.version).toBe(2);
|
||||
const b = queueRoundItem(a, { kind: 'action', text: 'two' });
|
||||
expect(b.version).toBe(3);
|
||||
// and never backwards: two consecutive adds from the same base both
|
||||
// advance past it, so a late snapshot of the older state is always older.
|
||||
const b2 = queueRoundItem(baseState(), { kind: 'action', text: 'two' });
|
||||
expect(b2.version).toBe(2);
|
||||
expect(b.version).toBeGreaterThan(b2.version);
|
||||
});
|
||||
|
||||
it('treats a legacy state without a version field as version 0', () => {
|
||||
const legacy = { ...baseState() } as Omit<GameState, 'version'> & { version?: number };
|
||||
delete legacy.version; // a row saved before the version field existed
|
||||
// simulate a legacy row coming back from SQLite: the JSON has no field.
|
||||
const after = queueRoundItem(legacy as unknown as GameState, {
|
||||
kind: 'action',
|
||||
text: 'first after upgrade',
|
||||
});
|
||||
expect(after.version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeRoundItem', () => {
|
||||
it('removes the item at the index, bumps the version, and leaves the input untouched', () => {
|
||||
const state = baseState();
|
||||
state.pending = [
|
||||
{ kind: 'action', text: 'one' },
|
||||
{ kind: 'diplomacy', to: 'us', text: 'two' },
|
||||
{ kind: 'action', text: 'three' },
|
||||
];
|
||||
const next = removeRoundItem(state, 1);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.version).toBe(2);
|
||||
expect(next!.pending).toEqual([
|
||||
{ kind: 'action', text: 'one' },
|
||||
{ kind: 'action', text: 'three' },
|
||||
]);
|
||||
expect(state.pending).toHaveLength(3); // original untouched
|
||||
expect(state.version).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null for out-of-bounds or non-integer indices (route answers 400, nothing mutates)', () => {
|
||||
const state = baseState();
|
||||
state.pending = [{ kind: 'action', text: 'one' }];
|
||||
expect(removeRoundItem(state, -1)).toBeNull();
|
||||
expect(removeRoundItem(state, 1)).toBeNull(); // == length
|
||||
expect(removeRoundItem(state, 5)).toBeNull();
|
||||
expect(removeRoundItem(state, 0.5)).toBeNull();
|
||||
expect(removeRoundItem(state, Number.NaN)).toBeNull();
|
||||
expect(state.pending).toHaveLength(1); // still untouched
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTurnResult', () => {
|
||||
it('parses a fenced JSON block', () => {
|
||||
const r = parseTurnResult('```json\n{"date":"1939-09-03","events":[{"nation":"uk","type":"military","title":"t","text":"b","outcome":"success"}]}\n```');
|
||||
@@ -237,6 +444,23 @@ describe('applyTurnResult', () => {
|
||||
applyTurnResult(state, result);
|
||||
expect(JSON.stringify(state)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('clears the pending round on commit and bumps the version', () => {
|
||||
const state = baseState();
|
||||
state.pending = [{ kind: 'action', text: 'x' }];
|
||||
const result: TurnResult = {
|
||||
date: '1939-09-03',
|
||||
events: [],
|
||||
stateChanges: [],
|
||||
relations: [],
|
||||
territoryChanges: [],
|
||||
};
|
||||
const next = applyTurnResult(state, result);
|
||||
expect(next.pending).toEqual([]);
|
||||
expect(next.version).toBe(2);
|
||||
expect(state.pending).toHaveLength(1); // original untouched
|
||||
expect(state.version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMeaningful', () => {
|
||||
|
||||
+72
-21
@@ -9,6 +9,7 @@ import type {
|
||||
GameState,
|
||||
Preset,
|
||||
RelationStatus,
|
||||
RoundItem,
|
||||
StatKey,
|
||||
} from '../shared/types';
|
||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||
@@ -58,18 +59,35 @@ export function buildSystemPrompt(preset: Preset): string {
|
||||
'HARD CONSTRAINTS',
|
||||
'- Use only nation ids and ISO_A3 codes that exist in the provided state.',
|
||||
'- Prefer 1-4 events per turn; keep each text 1-3 sentences.',
|
||||
'- Keep your internal reasoning brief (a few sentences) and go straight to the JSON result; do not over-deliberate or re-derive facts already present in the state.',
|
||||
'- Keep every stat value between 0 and 100.',
|
||||
'- Only change borders when war or a decisive political shift justifies it.',
|
||||
'- If a field is not relevant this turn, set it to an empty array.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** The user message: a compact JSON snapshot + the player's action/chat. */
|
||||
export function buildUserPrompt(
|
||||
state: GameState,
|
||||
action?: string,
|
||||
chat?: { to: string; message: string },
|
||||
): string {
|
||||
/**
|
||||
* The user message: a compact JSON snapshot of the world plus the player's
|
||||
* queued round (`items`) — the free-text actions and diplomatic messages to be
|
||||
* simulated this period. `playerActions` is an LLM-friendly rendering of each
|
||||
* item; the raw `pending` list is also emitted so the deterministic stub (and any
|
||||
* other consumer) can read the exact items. The framing instruction tells the
|
||||
* model to simulate the combined consequences of the whole batch (or to let the
|
||||
* world advance on its own when the batch is empty) and return one TurnResult.
|
||||
*/
|
||||
export function buildUserPrompt(state: GameState, items: RoundItem[]): string {
|
||||
const player = state.nations[state.playerNationId];
|
||||
const playerName = player?.name ?? state.playerNationId;
|
||||
const playerActions = items.map((i) =>
|
||||
i.kind === 'action' ? i.text : { to: i.to ?? '', message: i.text },
|
||||
);
|
||||
const roundInstruction =
|
||||
items.length > 0
|
||||
? `This period the player (${playerName}) takes the following actions and sends the following diplomatic messages: ${JSON.stringify(
|
||||
playerActions,
|
||||
)}. Simulate the combined consequences across all nations and advance the world by one period. Return a single TurnResult.`
|
||||
: 'The player takes no action this period; let the world advance on its own momentum. Return a single TurnResult.';
|
||||
|
||||
const payload = {
|
||||
state: {
|
||||
turn: state.turn,
|
||||
@@ -78,7 +96,7 @@ export function buildUserPrompt(
|
||||
nations: Object.fromEntries(
|
||||
Object.entries(state.nations).map(([id, n]) => [id, nationForPrompt(n)]),
|
||||
),
|
||||
recentEvents: state.events.slice(-10).map((e) => ({
|
||||
recentEvents: state.events.slice(-5).map((e) => ({
|
||||
turn: e.turn,
|
||||
date: e.date,
|
||||
nation: e.nation,
|
||||
@@ -88,8 +106,9 @@ export function buildUserPrompt(
|
||||
outcome: e.outcome,
|
||||
})),
|
||||
},
|
||||
action: action ?? null,
|
||||
chat: chat ?? null,
|
||||
playerActions,
|
||||
pending: items,
|
||||
roundInstruction,
|
||||
};
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
@@ -266,9 +285,12 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
|
||||
*/
|
||||
export function applyTurnResult(state: GameState, result: TurnResult): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
|
||||
const actingTurn = state.turn; // this action resolves the current turn
|
||||
next.turn = state.turn + 1; // then we advance to the next turn to play
|
||||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
||||
// Committing a turn consumes the whole queued round — reset it for the next.
|
||||
next.pending = [];
|
||||
for (const ev of result.events) {
|
||||
next.events.push(makeEvent(ev, actingTurn, next.date));
|
||||
}
|
||||
@@ -302,22 +324,55 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably queue one item onto a round: append it to `pending` and return a new
|
||||
* state. This never advances the turn or the date — that only happens when a
|
||||
* round is committed via runTurn. Bumps `version` (a new revision, so stale SSE
|
||||
* snapshots can never clobber it client-side). Exported so the append-without-
|
||||
* advance behavior is unit-testable, and reused by the /action and /diplomacy
|
||||
* routes.
|
||||
*/
|
||||
export function queueRoundItem(state: GameState, item: RoundItem): GameState {
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1;
|
||||
next.pending.push(item);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably remove one item from the pending round by index and return a new
|
||||
* state (bumping `version`). Returns `null` when the index is missing or not a
|
||||
* valid, in-bounds integer position, so the route can answer 400 without
|
||||
* mutating anything. Reused by the server's /queue/remove route.
|
||||
*/
|
||||
export function removeRoundItem(state: GameState, index: number | undefined): GameState | null {
|
||||
if (index === undefined || !Number.isInteger(index) || index < 0 || index >= state.pending.length) {
|
||||
return null;
|
||||
}
|
||||
const next: GameState = JSON.parse(JSON.stringify(state));
|
||||
next.version = (state.version ?? 0) + 1;
|
||||
next.pending.splice(index, 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the
|
||||
* model, parse/validate, retry once with a nudge if invalid, and — if it is still
|
||||
* invalid — emit a generic "unclear" event and advance. Never throws on bad
|
||||
* model output; only the underlying transport/network can throw.
|
||||
* model ONCE over the whole pending batch, parse/validate, retry once with a
|
||||
* nudge if the result is empty/invalid, and — if it is still invalid — emit a
|
||||
* generic "unclear" event and advance. The committed state has `pending` cleared.
|
||||
* Never throws on bad model output; only the underlying transport/network can throw.
|
||||
*/
|
||||
export async function runTurn(opts: {
|
||||
llm: LlmClient;
|
||||
preset: Preset;
|
||||
state: GameState;
|
||||
action?: string;
|
||||
chat?: { to: string; message: string };
|
||||
items: RoundItem[];
|
||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||
const { llm, preset, state, action, chat } = opts;
|
||||
const { llm, preset, state, items } = opts;
|
||||
const system = buildSystemPrompt(preset);
|
||||
const user = buildUserPrompt(state, action, chat);
|
||||
const user = buildUserPrompt(state, items);
|
||||
const nudge =
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above for this batch — no prose, no fences.';
|
||||
|
||||
let result = parseTurnResult(await llm.chat([
|
||||
{ role: 'system', content: system },
|
||||
@@ -329,11 +384,7 @@ export async function runTurn(opts: {
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
{ role: 'assistant', content: '' },
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.',
|
||||
},
|
||||
{ role: 'user', content: nudge },
|
||||
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
|
||||
}
|
||||
|
||||
|
||||
+79
-35
@@ -9,11 +9,16 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { deepClone } from '../shared/protocol';
|
||||
import type { CreateGameRequest, TurnRequest } from '../shared/protocol';
|
||||
import type {
|
||||
AddActionRequest,
|
||||
AddDiplomacyRequest,
|
||||
CreateGameRequest,
|
||||
RemoveQueueItemRequest,
|
||||
} from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
import { createLlmClient } from './llm';
|
||||
import { makeStubLlmClient } from './stub';
|
||||
import { askAdvisor, runTurn } from './engine';
|
||||
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
||||
import { buildRegionMap } from './map';
|
||||
import { createPresetStore } from './presets';
|
||||
import { openDb, type Db } from './db';
|
||||
@@ -113,6 +118,8 @@ app.post('/api/games', (req: Request, res) => {
|
||||
date: preset.startDate,
|
||||
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1, // the first saved revision; every later mutation bumps it
|
||||
};
|
||||
db.save(state);
|
||||
res.json({ game: state });
|
||||
@@ -149,46 +156,83 @@ app.get('/api/games/:id/map', (req, res) => {
|
||||
res.json({ regions: buildRegionMap(game) });
|
||||
});
|
||||
|
||||
async function handleTurn(id: string, body: TurnRequest): Promise<{ state: GameState; result: unknown }> {
|
||||
const game = db.load(id);
|
||||
if (!game) throw new Error('not_found');
|
||||
const preset = presetStore.get(game.presetId);
|
||||
if (!preset) throw new Error('preset_missing');
|
||||
const { state, result } = await runTurn({
|
||||
llm,
|
||||
preset,
|
||||
state: game,
|
||||
action: body?.action,
|
||||
chat: body?.chat,
|
||||
});
|
||||
db.save(state);
|
||||
broadcast(id, 'turn', { state, result });
|
||||
return { state, result };
|
||||
}
|
||||
|
||||
app.post('/api/games/:id/turn', async (req, res) => {
|
||||
try {
|
||||
const { state, result } = await handleTurn(req.params.id, req.body as TurnRequest);
|
||||
res.json({ state, result });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg === 'not_found' || msg === 'preset_missing') {
|
||||
return res.status(404).json({ error: msg });
|
||||
}
|
||||
res.status(502).json({ error: `turn failed: ${msg}` });
|
||||
}
|
||||
// Delete a saved game entirely. Fast, non-LLM. 404 if it doesn't exist; on
|
||||
// success broadcast an SSE `error` to any other open tab watching it so it
|
||||
// returns home (see the `event: error` handling in src/lib/sse.ts).
|
||||
app.delete('/api/games/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
const removed = db.delete(id);
|
||||
if (!removed) return res.status(404).json({ error: 'game not found' });
|
||||
broadcast(id, 'error', { message: 'game deleted' });
|
||||
res.json({ deleted: true });
|
||||
});
|
||||
|
||||
// Queue a free-text player action onto the pending round. No LLM call and no
|
||||
// turn advance — the round is only committed (in one turn) by "Jump forward".
|
||||
app.post('/api/games/:id/action', (req, res) => {
|
||||
const id = req.params.id;
|
||||
const game = db.load(id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
const text = (req.body as AddActionRequest | undefined)?.text?.trim();
|
||||
if (!text) return res.status(400).json({ error: 'empty action' });
|
||||
const next = queueRoundItem(game, { kind: 'action', text });
|
||||
db.save(next);
|
||||
broadcast(id, 'state', { state: next });
|
||||
res.json({ game: next });
|
||||
});
|
||||
|
||||
// Queue a diplomatic message to a target nation onto the pending round. No LLM
|
||||
// call and no advance.
|
||||
app.post('/api/games/:id/diplomacy', (req, res) => {
|
||||
const id = req.params.id;
|
||||
const game = db.load(id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
const body = (req.body ?? {}) as AddDiplomacyRequest;
|
||||
const message = body.message?.trim();
|
||||
if (!body.to || !message) return res.status(400).json({ error: 'empty message' });
|
||||
const next = queueRoundItem(game, { kind: 'diplomacy', to: body.to, text: message });
|
||||
db.save(next);
|
||||
broadcast(id, 'state', { state: next });
|
||||
res.json({ game: next });
|
||||
});
|
||||
|
||||
// Remove one item from the pending round, by its index in `game.pending`. No
|
||||
// LLM call, no turn advance — same fast save → broadcast → respond order as the
|
||||
// add routes above. The mutated state bumps `version`, so clients (which apply
|
||||
// states "never go backwards") always reconcile to it.
|
||||
app.post('/api/games/:id/queue/remove', (req, res) => {
|
||||
const id = req.params.id;
|
||||
const game = db.load(id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
const index = (req.body as RemoveQueueItemRequest | undefined)?.index;
|
||||
const next = removeRoundItem(game, index);
|
||||
if (!next) return res.status(400).json({ error: 'invalid queue index' });
|
||||
db.save(next);
|
||||
broadcast(id, 'state', { state: next });
|
||||
res.json({ game: next });
|
||||
});
|
||||
|
||||
// Jump forward: commit the whole pending round (or an empty one) to the model
|
||||
// in a single turn. applyTurnResult clears the pending queue on the returned
|
||||
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
|
||||
app.post('/api/games/:id/jump', async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const game = db.load(id);
|
||||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||
const preset = presetStore.get(game.presetId);
|
||||
if (!preset) return res.status(404).json({ error: 'preset_missing' });
|
||||
try {
|
||||
// Jump forward = a turn with no player action (auto-simulate the world).
|
||||
const { state, result } = await handleTurn(req.params.id, {});
|
||||
const { state, result } = await runTurn({
|
||||
llm,
|
||||
preset,
|
||||
state: game,
|
||||
items: game.pending,
|
||||
});
|
||||
db.save(state);
|
||||
broadcast(id, 'turn', { state, result });
|
||||
res.json({ state, result });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg === 'not_found' || msg === 'preset_missing') {
|
||||
return res.status(404).json({ error: msg });
|
||||
}
|
||||
res.status(502).json({ error: `jump failed: ${msg}` });
|
||||
}
|
||||
});
|
||||
|
||||
+157
-22
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createLlmClient, DEFAULT_MAX_TOKENS } from './llm';
|
||||
|
||||
function json(body: unknown) {
|
||||
@@ -10,42 +10,88 @@ function json(body: unknown) {
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function httpError(status: number, text = 'upstream down') {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
text: async () => text,
|
||||
json: async () => ({}),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('createLlmClient (injectable transport)', () => {
|
||||
it('chat posts model+messages to {base}/chat/completions and returns content', async () => {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const sent: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
sent.push({ url, init });
|
||||
return json({ choices: [{ message: { content: 'hello back' } }] });
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1/', model: 'm1' });
|
||||
const out = await llm.chat([{ role: 'user', content: 'hi' }], { maxTokens: 10, temperature: 0.5 });
|
||||
expect(out).toBe('hello back');
|
||||
expect(calls[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized
|
||||
const body = JSON.parse(calls[0].init!.body as string);
|
||||
expect(sent[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized
|
||||
const body = JSON.parse(sent[0].init!.body as string);
|
||||
// The wire key is `max_tokens` (snake_case), never camelCase `maxTokens`.
|
||||
expect(body).toEqual({
|
||||
model: 'm1',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
temperature: 0.5,
|
||||
max_tokens: 10,
|
||||
reasoning_effort: 'low',
|
||||
});
|
||||
// No auth header: the live vLLM endpoint requires no API key.
|
||||
expect(calls[0].init!.headers).toEqual({ 'content-type': 'application/json' });
|
||||
expect(sent[0].init!.headers).toEqual({ 'content-type': 'application/json' });
|
||||
});
|
||||
|
||||
it('chat uses a generous default max_tokens when the caller omits it', async () => {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const sent: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
sent.push({ url, init });
|
||||
return json({ choices: [{ message: { content: 'ok' } }] });
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' });
|
||||
await llm.chat([{ role: 'user', content: 'hi' }]);
|
||||
const body = JSON.parse(calls[0].init!.body as string);
|
||||
const body = JSON.parse(sent[0].init!.body as string);
|
||||
expect(body.max_tokens).toBe(DEFAULT_MAX_TOKENS);
|
||||
expect(body.max_tokens).toBe(4000);
|
||||
});
|
||||
|
||||
it('chat sends reasoning_effort "low" by default', async () => {
|
||||
const sent: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
sent.push({ url, init });
|
||||
return json({ choices: [{ message: { content: 'ok' } }] });
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' });
|
||||
await llm.chat([{ role: 'user', content: 'hi' }]);
|
||||
const body = JSON.parse(sent[0].init!.body as string);
|
||||
expect(body.reasoning_effort).toBe('low');
|
||||
});
|
||||
|
||||
it('chat omits reasoning_effort when it is "off" or empty', async () => {
|
||||
const sent: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
sent.push({ url, init });
|
||||
return json({ choices: [{ message: { content: 'ok' } }] });
|
||||
};
|
||||
for (const effort of ['off', 'OFF', '']) {
|
||||
sent.length = 0;
|
||||
const llm = createLlmClient({
|
||||
transport,
|
||||
apiBase: 'http://x/v1',
|
||||
model: 'm1',
|
||||
reasoningEffort: effort,
|
||||
});
|
||||
await llm.chat([{ role: 'user', content: 'hi' }]);
|
||||
const body = JSON.parse(sent[0].init!.body as string);
|
||||
expect(body.reasoning_effort, `effort=${JSON.stringify(effort)}`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('listModels returns data[].id from GET {base}/models', async () => {
|
||||
const transport = async (url: string) => {
|
||||
expect(url).toBe('http://x/v1/models');
|
||||
@@ -55,21 +101,110 @@ describe('createLlmClient (injectable transport)', () => {
|
||||
expect(await llm.listModels()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('chat throws on non-2xx with the HTTP status in the message', async () => {
|
||||
const transport = async () =>
|
||||
({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'upstream down',
|
||||
json: async () => ({}),
|
||||
} as unknown as Response);
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 503/);
|
||||
it('chat retries 5xx and succeeds once the transport recovers (500, 500 → 200)', async () => {
|
||||
const sent: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
sent.push({ url, init });
|
||||
if (sent.length < 3) return httpError(500);
|
||||
return json({ choices: [{ message: { content: 'recovered' } }] });
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
||||
const out = await llm.chat([{ role: 'user', content: 'hi' }]);
|
||||
expect(out).toBe('recovered');
|
||||
expect(sent).toHaveLength(3); // two failures, then success on the third attempt
|
||||
});
|
||||
|
||||
it('chat throws when the response has no content', async () => {
|
||||
const transport = async () => json({ choices: [] });
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
||||
it('chat gives up after exactly 3 attempts when the transport always returns 5xx', async () => {
|
||||
const sent: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
sent.push({ url, init });
|
||||
return httpError(500, 'still down');
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
||||
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 500/);
|
||||
expect(sent).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('chat does not retry on 4xx (deterministic client error → first attempt only)', async () => {
|
||||
const sent: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
sent.push({ url, init });
|
||||
return httpError(400, 'bad request');
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
||||
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 400/);
|
||||
expect(sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('chat retries a null content (truncation) and then succeeds (null → content)', async () => {
|
||||
const sent: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
sent.push({ url, init });
|
||||
if (sent.length === 1) return json({ choices: [{ message: { content: null } }] });
|
||||
return json({ choices: [{ message: { content: 'real content' } }] });
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
||||
const out = await llm.chat([{ role: 'user', content: 'hi' }]);
|
||||
expect(out).toBe('real content');
|
||||
expect(sent).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('chat rejects with the HTTP status after 3 attempts when 5xx never recovers', async () => {
|
||||
const sent: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
sent.push({ url, init });
|
||||
return httpError(503);
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
||||
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 503/);
|
||||
expect(sent).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('chat rejects with a content error after 3 attempts when content is never present', async () => {
|
||||
const sent: { url: string; init?: RequestInit }[] = [];
|
||||
const transport = async (url: string, init?: RequestInit) => {
|
||||
sent.push({ url, init });
|
||||
return json({ choices: [] });
|
||||
};
|
||||
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
||||
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/no choices/);
|
||||
expect(sent).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('chat logs one line on success and one on final failure', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log');
|
||||
const errorSpy = vi.spyOn(console, 'error');
|
||||
|
||||
const okTransport = async () =>
|
||||
json({ choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }] });
|
||||
const okLlm = createLlmClient({ transport: okTransport, apiBase: 'http://x/v1', model: 'm' });
|
||||
await okLlm.chat([{ role: 'user', content: 'hi' }]);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
'[llm] chat ok',
|
||||
expect.objectContaining({ ms: expect.any(Number), finishReason: 'stop' }),
|
||||
);
|
||||
|
||||
const failSent: { url: string; init?: RequestInit }[] = [];
|
||||
const failTransport = async (url: string, init?: RequestInit) => {
|
||||
failSent.push({ url, init });
|
||||
return httpError(502, 'bad gateway');
|
||||
};
|
||||
const failLlm = createLlmClient({
|
||||
transport: failTransport,
|
||||
apiBase: 'http://x/v1',
|
||||
model: 'm',
|
||||
retryBaseDelayMs: 1,
|
||||
});
|
||||
await expect(failLlm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 502/);
|
||||
expect(failSent).toHaveLength(3);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[llm] chat failed',
|
||||
expect.objectContaining({
|
||||
attempts: 3,
|
||||
lastStatus: 502,
|
||||
message: expect.stringContaining('HTTP 502'),
|
||||
totalMs: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+221
-36
@@ -1,18 +1,28 @@
|
||||
// Thin OpenAI chat-completions client over native fetch (mirrors mesh-bot/src/llm).
|
||||
// The transport is injectable so unit tests and the deterministic e2e stub can
|
||||
// replace the network call. Config is read from env, never hardcoded:
|
||||
// LLM_API_BASE (default https://ai.kkhost.pl/v1 — a vLLM endpoint, no API key)
|
||||
// LLM_MODEL (default qwen3.8-27b — a vLLM *reasoning* model)
|
||||
// LLM_TIMEOUT_MS (default 180000 — the 27B reasoning model can be slow)
|
||||
// LLM_API_BASE (default https://ai.kkhost.pl/v1 — a vLLM endpoint, no API key)
|
||||
// LLM_MODEL (default qwen3.8-27b — a vLLM *reasoning* model)
|
||||
// LLM_TIMEOUT_MS (default 180000 — the 27B reasoning model can be slow)
|
||||
// LLM_MAX_TOKENS (default 4000 — max *output* tokens per request)
|
||||
// LLM_REASONING_EFFORT (default "low" — sent as top-level `reasoning_effort`;
|
||||
// empty or "off" omits the field entirely)
|
||||
//
|
||||
// The live model is a REASONING model: it emits its chain-of-thought in the
|
||||
// response's `reasoning` field and the final answer in `content`. It therefore
|
||||
// needs a generous `max_tokens` budget to finish thinking before writing
|
||||
// `content` — a too-small budget makes it run out mid-reasoning and return a
|
||||
// null `content`. We always send an explicit, generous `max_tokens` (the
|
||||
// OpenAI/vLLM wire key — NOT camelCase `maxTokens`, which the server ignores)
|
||||
// and read only `choices[0].message.content`, ignoring `reasoning`. No auth
|
||||
// header is added (the endpoint requires no key).
|
||||
// response's `reasoning` field and the final answer in `content`. Sending
|
||||
// top-level `reasoning_effort: "low"` cuts the CoT by ~85% (~4000 →
|
||||
// ~400–600 tokens), so it fits comfortably inside the `max_tokens` budget and
|
||||
// `content` comes back populated. (Other knobs misbehave on this build:
|
||||
// "minimal" produced MORE CoT than "low", `chat_template_kwargs:
|
||||
// {enable_thinking:false}` intermittently emits invalid JSON, and `/no_think`
|
||||
// is silently ignored.) We read only `choices[0].message.content`, ignoring
|
||||
// `reasoning`. No auth header is added (the endpoint requires no key).
|
||||
//
|
||||
// Reliability: `chat()` retries transient failures — timeout/abort, HTTP 5xx,
|
||||
// network errors, and null/empty `content` (truncation safety net) — with up
|
||||
// to 3 total attempts and 2s→3s backoff (±20% jitter). HTTP 4xx is a
|
||||
// deterministic client error and throws immediately, with no retry. Every
|
||||
// call is logged: one line on success, one on final failure.
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
@@ -36,14 +46,29 @@ export const DEFAULT_BASE = 'https://ai.kkhost.pl/v1';
|
||||
export const DEFAULT_MODEL = 'qwen3.8-27b';
|
||||
|
||||
/**
|
||||
* Generous token budget for every chat request. The live qwen3.8-27b is a
|
||||
* Default max *output* tokens per chat request. The live qwen3.8-27b is a
|
||||
* reasoning model: it spends `reasoning_tokens` on its chain-of-thought before
|
||||
* writing the final answer into `content`. A small budget makes it run out
|
||||
* mid-reasoning and return a null `content`, so we give it room to finish both.
|
||||
* `max_tokens` is always set in the request body, even when a caller omits it.
|
||||
* writing the final answer into `content`. With `reasoning_effort: "low"` a
|
||||
* realistic turn uses ~800–1100 output tokens, so 4000 is ~4× headroom while
|
||||
* staying under the upstream ~90s proxy wall. `max_tokens` is always set in the
|
||||
* request body (the vLLM/OpenAI wire key — NOT camelCase `maxTokens`, which the
|
||||
* server ignores), even when a caller omits it.
|
||||
*/
|
||||
export const DEFAULT_MAX_TOKENS = 4000;
|
||||
|
||||
/**
|
||||
* Default for `LLM_REASONING_EFFORT`. Sending top-level `reasoning_effort:
|
||||
* "low"` to the vLLM model cuts its chain-of-thought by ~85%, which is what
|
||||
* keeps `content` from being truncated to null.
|
||||
*/
|
||||
export const DEFAULT_REASONING_EFFORT = 'low';
|
||||
|
||||
/** Total attempts `chat()` makes before giving up (1 initial + 2 retries). */
|
||||
export const MAX_ATTEMPTS = 3;
|
||||
|
||||
/** Base retry backoff: retry 1 waits 2000ms, retry 2 waits 3000ms, each ±20% jitter. */
|
||||
const RETRY_BASE_DELAY_MS = 2000;
|
||||
|
||||
const fetchTransport: Transport = (url, init) => fetch(url, init);
|
||||
|
||||
export interface LlmClientDeps {
|
||||
@@ -51,6 +76,91 @@ export interface LlmClientDeps {
|
||||
apiBase?: string;
|
||||
model?: string;
|
||||
timeoutMs?: number;
|
||||
maxTokens?: number;
|
||||
reasoningEffort?: string;
|
||||
/**
|
||||
* Base delay (ms) for the retry backoff, overriding RETRY_BASE_DELAY_MS.
|
||||
* Mostly useful in tests to keep the suite fast.
|
||||
*/
|
||||
retryBaseDelayMs?: number;
|
||||
}
|
||||
|
||||
/** parseInt with a fallback for unset/empty/malformed values. */
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) return fallback;
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
/** Trimmed env value, or undefined when unset/empty. */
|
||||
function envStr(name: string): string | undefined {
|
||||
const raw = process.env[name]?.trim();
|
||||
return raw ? raw : undefined;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Backoff before retry `n` (1-based): `2000 * 1.5^(n-1)`, ±20% jitter. */
|
||||
function backoffDelay(retry: number, baseMs: number): number {
|
||||
const base = baseMs * 1.5 ** (retry - 1);
|
||||
return base * (0.8 + Math.random() * 0.4);
|
||||
}
|
||||
|
||||
/** Network-level error codes that mark a transient failure. */
|
||||
const RETRYABLE_NET_CODES = new Set([
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'ECONNABORTED',
|
||||
'EPIPE',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN',
|
||||
'UND_ERR_CONNECT_TIMEOUT',
|
||||
'UND_ERR_SOCKET',
|
||||
'UND_ERR_REQ_SOCKET',
|
||||
'UND_ERR_REQ_RETRY',
|
||||
]);
|
||||
|
||||
/** True for a timeout/abort (an AbortSignal that fired, a TimeoutError, or AbortError). */
|
||||
function isTimeoutOrAbort(err: unknown): boolean {
|
||||
let e: unknown = err;
|
||||
for (let i = 0; i < 3 && e; i++) {
|
||||
if (e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError')) return true;
|
||||
e = (e as { cause?: unknown })?.cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True for a transient network failure (ECONNRESET / ECONNREFUSED / socket closed / …). */
|
||||
function isNetworkError(err: unknown): boolean {
|
||||
let e: unknown = err;
|
||||
let msg = '';
|
||||
for (let i = 0; i < 3 && e; i++) {
|
||||
if (e instanceof Error) {
|
||||
msg += ` ${e.message}`;
|
||||
const code = (e as Error & { code?: string }).code;
|
||||
if (code && RETRYABLE_NET_CODES.has(code)) return true;
|
||||
} else {
|
||||
msg += ` ${String(e)}`;
|
||||
}
|
||||
e = (e as { cause?: unknown })?.cause;
|
||||
}
|
||||
return /socket closed|socket hang up|connection reset|connection refused|other side closed|fetch failed/i.test(
|
||||
msg,
|
||||
);
|
||||
}
|
||||
|
||||
/** One transport attempt, classified as success / retryable / fatal. */
|
||||
interface Attempt {
|
||||
status?: number;
|
||||
error?: Error;
|
||||
retryable: boolean;
|
||||
content?: string;
|
||||
finishReason?: string | null;
|
||||
}
|
||||
|
||||
export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
@@ -61,36 +171,111 @@ export function createLlmClient(deps: LlmClientDeps = {}): LlmClient {
|
||||
deps.timeoutMs ? String(deps.timeoutMs) : (process.env.LLM_TIMEOUT_MS ?? '180000'),
|
||||
10,
|
||||
);
|
||||
const maxTokens = deps.maxTokens ?? envInt('LLM_MAX_TOKENS', DEFAULT_MAX_TOKENS);
|
||||
const effort = deps.reasoningEffort ?? envStr('LLM_REASONING_EFFORT') ?? DEFAULT_REASONING_EFFORT;
|
||||
// "off" (case-insensitive) or empty disables the thinking knob entirely.
|
||||
const sendEffort = effort !== '' && effort.toLowerCase() !== 'off';
|
||||
const retryBaseMs = deps.retryBaseDelayMs ?? RETRY_BASE_DELAY_MS;
|
||||
|
||||
/**
|
||||
* One POST to /chat/completions. Classifies the outcome:
|
||||
* - success → content + finish_reason
|
||||
* - retryable → timeout/abort, HTTP 5xx, network error, null/empty content
|
||||
* - fatal → HTTP 4xx (deterministic client error) or an unexpected throw
|
||||
*/
|
||||
async function attemptOnce(payload: string, signal: AbortSignal): Promise<Attempt> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await transport(`${apiBase}/chat/completions`, {
|
||||
method: 'POST',
|
||||
// No auth header: the ai.kkhost.pl vLLM endpoint requires no API key.
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: payload,
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
// The transport threw: our AbortSignal fired (timeout/abort) or a
|
||||
// network-level failure (ECONNRESET / ECONNREFUSED / socket closed).
|
||||
return { error: e, retryable: signal.aborted || isTimeoutOrAbort(e) || isNetworkError(e) };
|
||||
}
|
||||
|
||||
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
|
||||
const payload: Record<string, unknown> = { model, messages };
|
||||
if (opts.temperature != null) payload.temperature = opts.temperature;
|
||||
// Always send an explicit, generous `max_tokens` (the vLLM/OpenAI wire key —
|
||||
// camelCase `maxTokens` is ignored by the server) so the reasoning model has
|
||||
// room to finish `reasoning` before emitting `content`.
|
||||
payload.max_tokens = opts.maxTokens ?? DEFAULT_MAX_TOKENS;
|
||||
const res = await transport(`${apiBase}/chat/completions`, {
|
||||
method: 'POST',
|
||||
// No auth header: the ai.kkhost.pl vLLM endpoint requires no API key.
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`);
|
||||
return {
|
||||
status: res.status,
|
||||
error: new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`),
|
||||
// 4xx is a deterministic client error → do not retry; 5xx is retryable.
|
||||
retryable: res.status >= 500 && res.status < 600,
|
||||
};
|
||||
}
|
||||
// Reasoning models also return their chain-of-thought in a separate
|
||||
// `reasoning` field. We deliberately ignore it and use only the final
|
||||
// answer, which lives in `content`.
|
||||
|
||||
const body = (await res.json()) as {
|
||||
choices?: { message?: { content?: string; reasoning?: string | null } }[];
|
||||
choices?: {
|
||||
finish_reason?: string | null;
|
||||
message?: { content?: string; reasoning?: string | null };
|
||||
}[];
|
||||
};
|
||||
// The reasoning model can exhaust its output budget on the CoT and return
|
||||
// a null `content` — treat that as a retryable truncation, not a success.
|
||||
const content = body.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('chat failed: no choices[0].message.content in response');
|
||||
if (typeof content !== 'string' || content === '') {
|
||||
return {
|
||||
status: res.status,
|
||||
error: new Error('chat failed: no choices[0].message.content in response'),
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
return content;
|
||||
return {
|
||||
status: res.status,
|
||||
retryable: false,
|
||||
content,
|
||||
finishReason: body.choices?.[0]?.finish_reason ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise<string> {
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
messages,
|
||||
// The wire key is `max_tokens` (snake_case) — camelCase is ignored by the
|
||||
// server. With `reasoning_effort: "low"` the CoT fits inside this budget,
|
||||
// so `content` comes back populated.
|
||||
max_tokens: opts.maxTokens ?? maxTokens,
|
||||
};
|
||||
if (opts.temperature != null) payload.temperature = opts.temperature;
|
||||
if (sendEffort) payload.reasoning_effort = effort;
|
||||
|
||||
const totalStart = Date.now();
|
||||
let lastStatus: number | undefined;
|
||||
let lastError: Error = new Error('chat failed: no attempt completed');
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
const out = await attemptOnce(JSON.stringify(payload), AbortSignal.timeout(timeoutMs));
|
||||
if (out.status !== undefined) lastStatus = out.status;
|
||||
if (out.error) lastError = out.error;
|
||||
|
||||
if (out.content !== undefined) {
|
||||
console.log('[llm] chat ok', {
|
||||
ms: Date.now() - totalStart,
|
||||
finishReason: out.finishReason ?? null,
|
||||
});
|
||||
return out.content;
|
||||
}
|
||||
// Give up on a non-retryable failure, or when the last attempt is spent.
|
||||
if (!out.retryable || attempt === MAX_ATTEMPTS) {
|
||||
console.error('[llm] chat failed', {
|
||||
attempts: attempt,
|
||||
lastStatus,
|
||||
message: lastError.message,
|
||||
totalMs: Date.now() - totalStart,
|
||||
});
|
||||
throw lastError;
|
||||
}
|
||||
await sleep(backoffDelay(attempt, retryBaseMs));
|
||||
}
|
||||
// Unreachable: every loop path returns or throws.
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function listModels(): Promise<string[]> {
|
||||
|
||||
+67
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildRegionMap } from './map';
|
||||
import { nationColor } from '../shared/protocol';
|
||||
import { nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
||||
import type { GameState } from '../shared/types';
|
||||
|
||||
const state: GameState = {
|
||||
@@ -29,8 +29,13 @@ const state: GameState = {
|
||||
},
|
||||
},
|
||||
events: [],
|
||||
pending: [],
|
||||
version: 1,
|
||||
};
|
||||
|
||||
// The RegionMap built from the fixture: the authoritative ISO_A3 → owner source.
|
||||
const regions: RegionMap = buildRegionMap(state);
|
||||
|
||||
describe('buildRegionMap', () => {
|
||||
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
|
||||
const m = buildRegionMap(state);
|
||||
@@ -42,6 +47,67 @@ describe('buildRegionMap', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('regionOwner', () => {
|
||||
it('resolves an owned region\'s ISO_A3 to its owner record', () => {
|
||||
expect(regionOwner(regions, 'GBR')).toEqual({
|
||||
color: nationColor('uk'),
|
||||
owner: 'UK',
|
||||
ownerId: 'uk',
|
||||
});
|
||||
expect(regionOwner(regions, 'DEU')).toEqual({
|
||||
color: nationColor('de'),
|
||||
owner: 'DE',
|
||||
ownerId: 'de',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
||||
expect(regionOwner(regions, 'FRA')).toBeUndefined();
|
||||
expect(regionOwner(regions, 'USA')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an empty or missing code', () => {
|
||||
expect(regionOwner(regions, '')).toBeUndefined();
|
||||
expect(regionOwner(regions, null)).toBeUndefined();
|
||||
expect(regionOwner(regions, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #6 regression guard. The click/hover handlers resolve the owning nation from a
|
||||
* map feature. MapLibre repacks a GeoJSON source's top-level `id` (the ISO_A3
|
||||
* string) into a *numeric index* when it stores the source, so a feature the map
|
||||
* hands back at interaction time has a number for `id` and the ISO_A3 only lives
|
||||
* in `properties.regionId`. This test pins the exact data shape and proves the
|
||||
* fix: resolution via `properties.regionId` works, while the pre-fix path that
|
||||
* read `feature.id` does not.
|
||||
*/
|
||||
describe('#6 — owner must be resolved from properties.regionId, not the repacked feature.id', () => {
|
||||
// The shape MapLibre returns for a source feature after setData(): `id` is a
|
||||
// number (here 0, as observed in the live map), and the ISO_A3 is preserved
|
||||
// only in properties (the client stamps it as `regionId`).
|
||||
const repackedFeature = {
|
||||
id: 0,
|
||||
properties: { name: 'DE', regionId: 'DEU' },
|
||||
};
|
||||
|
||||
it('resolves the owner from properties.regionId (the fix)', () => {
|
||||
const code = typeof repackedFeature.properties.regionId === 'string' ? repackedFeature.properties.regionId : '';
|
||||
expect(regionOwner(regions, code)).toEqual({
|
||||
color: nationColor('de'),
|
||||
owner: 'DE',
|
||||
ownerId: 'de',
|
||||
});
|
||||
});
|
||||
|
||||
it('the old path (feature.id as a string key) does NOT resolve — the bug', () => {
|
||||
// String(0) === '0' is not a valid ISO_A3 key, so the pre-fix lookup failed
|
||||
// and the click popup reported "No owner" even for owned regions.
|
||||
expect(regions[String(repackedFeature.id)]).toBeUndefined();
|
||||
expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nationColor', () => {
|
||||
it('is deterministic per id and distinct across different ids', () => {
|
||||
expect(nationColor('uk')).toBe(nationColor('uk'));
|
||||
|
||||
+35
-30
@@ -2,11 +2,12 @@
|
||||
// sets LLM_STUB=1) so the whole loop is reproducible with no live model. It
|
||||
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
|
||||
import { createLlmClient, type ChatMessage, type Transport } from './llm';
|
||||
import { addDays, clampStat, type TurnResult } from '../shared/protocol';
|
||||
import { addDays, clampStat, type TurnEventSpec, type TurnResult } from '../shared/protocol';
|
||||
import type { RoundItem } from '../shared/types';
|
||||
|
||||
export const ADVISOR_MARKER = '<<ADVISOR>>';
|
||||
|
||||
/** The parsed body of the user message the engine sends (see engine.buildPrompt). */
|
||||
/** The parsed body of the user message the engine sends (see engine.buildUserPrompt). */
|
||||
export interface StubPayload {
|
||||
state?: {
|
||||
turn: number;
|
||||
@@ -14,8 +15,8 @@ export interface StubPayload {
|
||||
playerNationId: string;
|
||||
nations: Record<string, { stats: Record<string, number> }>;
|
||||
};
|
||||
action?: string | null;
|
||||
chat?: { to: string; message: string } | null;
|
||||
/** The raw pending round the engine emitted (one entry per queued item). */
|
||||
pending?: RoundItem[];
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
@@ -24,8 +25,12 @@ function truncate(s: string, n: number): string {
|
||||
|
||||
/**
|
||||
* Pure deterministic TurnResult for a parsed payload. Bumps the player nation's
|
||||
* military by 5 each turn (clamped to 0–100) and emits one event, so a caller can
|
||||
* predict the exact next state for assertions.
|
||||
* military by 5 each turn (clamped to 0–100) and emits one event per queued
|
||||
* round item (action → military/success, diplomacy → diplomatic/partial), or a
|
||||
* single political "The world moves on" event when the round is empty — so a
|
||||
* caller can predict the exact next state for assertions. It always sets the
|
||||
* player's military to (current + 5) and allies the player with the first other
|
||||
* nation, advancing the date by 2 days.
|
||||
*/
|
||||
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const state = p.state;
|
||||
@@ -35,36 +40,36 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
||||
const otherId = state
|
||||
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
|
||||
: 'us';
|
||||
const items = p.pending ?? [];
|
||||
|
||||
const events = p.action
|
||||
? [
|
||||
{
|
||||
nation: pid,
|
||||
type: 'military' as const,
|
||||
title: `Action: ${truncate(p.action, 60)}`,
|
||||
text: 'Your move takes effect across the region.',
|
||||
outcome: 'success' as const,
|
||||
},
|
||||
]
|
||||
: p.chat
|
||||
const events: TurnEventSpec[] =
|
||||
items.length === 0
|
||||
? [
|
||||
{
|
||||
nation: pid,
|
||||
type: 'diplomatic' as const,
|
||||
title: 'Diplomatic outreach',
|
||||
text: 'Your message is received by the target nation.',
|
||||
outcome: 'partial' as const,
|
||||
type: 'political',
|
||||
title: 'The world moves on',
|
||||
text: 'Weeks pass without a decisive event.',
|
||||
outcome: 'neutral',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
nation: pid,
|
||||
type: 'political' as const,
|
||||
title: 'The world moves on',
|
||||
text: 'Weeks pass; the great powers posture and rearm quietly.',
|
||||
outcome: 'neutral' as const,
|
||||
},
|
||||
];
|
||||
: items.map((it) =>
|
||||
it.kind === 'action'
|
||||
? {
|
||||
nation: pid,
|
||||
type: 'military' as const,
|
||||
title: `Action: ${truncate(it.text, 60)}`,
|
||||
text: 'Your move takes effect across the region.',
|
||||
outcome: 'success' as const,
|
||||
}
|
||||
: {
|
||||
nation: pid,
|
||||
type: 'diplomatic' as const,
|
||||
title: 'Diplomatic outreach',
|
||||
text: 'Your message is received.',
|
||||
outcome: 'partial' as const,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
date: addDays(state?.date ?? '1939-09-01', 2),
|
||||
|
||||
+51
-5
@@ -56,6 +56,23 @@ export interface RegionOwner {
|
||||
}
|
||||
export type RegionMap = Record<string, RegionOwner>;
|
||||
|
||||
/**
|
||||
* Look up the owner record for a map region from the authoritative RegionMap by
|
||||
* its ISO_A3 code. Returns `undefined` for an empty/missing code or an unowned
|
||||
* region. Pure + shared: the client resolves a clicked or hovered map feature by
|
||||
* first reading the feature's stable ISO_A3 code (stored in `properties.regionId`
|
||||
* by the client when it repacks the source — see WorldMap) and then calling this.
|
||||
*
|
||||
* The code must NOT be taken from the feature's top-level `id`: MapLibre repacks
|
||||
* a GeoJSON source's `id` (an ISO_A3 string) into a numeric index when it stores
|
||||
* the source, so at click/hover time `feature.id` is a number, never the ISO_A3
|
||||
* — resolving from it is exactly the "no owner" bug (#6).
|
||||
*/
|
||||
export function regionOwner(regions: RegionMap, isoA3: string | null | undefined): RegionOwner | undefined {
|
||||
if (!isoA3) return undefined;
|
||||
return regions[isoA3];
|
||||
}
|
||||
|
||||
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||||
export type SseMessage =
|
||||
| { type: 'state'; state: GameState }
|
||||
@@ -72,17 +89,41 @@ export interface CreateGameResponse {
|
||||
game: GameState;
|
||||
}
|
||||
|
||||
export interface TurnRequest {
|
||||
/** Free-text player action. Omit for a "jump forward" auto turn. */
|
||||
action?: string;
|
||||
/** Optional diplomatic message to a target nation. */
|
||||
chat?: { to: string; message: string };
|
||||
/** Queue a free-text player action onto the pending round (no turn advance). */
|
||||
export interface AddActionRequest {
|
||||
text: string;
|
||||
}
|
||||
export interface AddActionResponse {
|
||||
game: GameState;
|
||||
}
|
||||
|
||||
/** Queue a diplomatic message onto the pending round (no turn advance). */
|
||||
export interface AddDiplomacyRequest {
|
||||
to: string;
|
||||
message: string;
|
||||
}
|
||||
export interface AddDiplomacyResponse {
|
||||
game: GameState;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Jump forward" — commit the whole pending round to the model in a single
|
||||
* turn. The body is empty; everything being committed lives in `game.pending`.
|
||||
*/
|
||||
export type JumpRequest = Record<string, never>;
|
||||
export interface TurnResponse {
|
||||
state: GameState;
|
||||
result: TurnResult;
|
||||
}
|
||||
|
||||
/** Remove one queued item from the pending round, by its index in `game.pending`. */
|
||||
export interface RemoveQueueItemRequest {
|
||||
index: number;
|
||||
}
|
||||
export interface RemoveQueueItemResponse {
|
||||
game: GameState;
|
||||
}
|
||||
|
||||
export interface AdvisorRequest {
|
||||
/** Optional prompt to steer the brainstorm. */
|
||||
prompt?: string;
|
||||
@@ -100,6 +141,11 @@ export interface GameSummary {
|
||||
date: string;
|
||||
}
|
||||
|
||||
/** Response for deleting a saved game (see `DELETE /api/games/:id`). */
|
||||
export interface DeleteGameResponse {
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic, stable per-nation color for the choropleth. Hashes the nation id
|
||||
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
|
||||
|
||||
@@ -74,6 +74,28 @@ export interface EventEntry {
|
||||
outcome: EventOutcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single item queued in the current round, waiting to be committed by a
|
||||
* "Jump forward". `kind: 'action'` is a free-text player move; `kind:
|
||||
* 'diplomacy'` is a message to a specific nation (`to`).
|
||||
*/
|
||||
export type RoundItem = {
|
||||
kind: 'action' | 'diplomacy';
|
||||
text: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Monotonic revision counter for a game, bumped by the server on every
|
||||
* mutation (queue add/remove, turn commit) and persisted with the state.
|
||||
* Clients use it to tell a fresh state from a stale one: an SSE `state`/`turn`
|
||||
* snapshot (e.g. the on-connect resync) that arrives *after* a fresher REST
|
||||
* response carries a lower version and must be ignored, never applied
|
||||
* (see the client's "never go backwards" rule in src/App.tsx). Legacy games
|
||||
* saved before this field existed are treated as version 0 by consumers.
|
||||
*/
|
||||
export type GameVersion = number;
|
||||
|
||||
/** The full, persisted state of one game. Saved to SQLite by id. */
|
||||
export interface GameState {
|
||||
id: string;
|
||||
@@ -84,6 +106,18 @@ export interface GameState {
|
||||
date: string;
|
||||
nations: Record<string, Nation>;
|
||||
events: EventEntry[];
|
||||
/**
|
||||
* Actions + diplomatic messages queued this round but not yet committed.
|
||||
* Adding an item never advances the turn; a "Jump forward" processes the whole
|
||||
* batch in a single turn and clears this back to `[]`.
|
||||
*/
|
||||
pending: RoundItem[];
|
||||
/**
|
||||
* Monotonic revision counter (see GameVersion). Bumped on every mutation so
|
||||
* clients can reject stale SSE snapshots; see the "never go backwards" rule
|
||||
* in src/App.tsx. Starts at 1 on game creation.
|
||||
*/
|
||||
version: GameVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+307
-116
@@ -7,8 +7,6 @@ import {
|
||||
ListTree,
|
||||
Loader2,
|
||||
Map as MapIcon,
|
||||
Minus,
|
||||
Plus,
|
||||
Radar,
|
||||
} from 'lucide-react';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
@@ -18,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
||||
import { cn } from './lib/utils';
|
||||
import PresetPicker from './components/PresetPicker';
|
||||
import NationSelect from './components/NationSelect';
|
||||
import WorldMap, { type MapApi } from './components/WorldMap';
|
||||
import WorldMap from './components/WorldMap';
|
||||
import FloatingWindow from './components/FloatingWindow';
|
||||
import MapLegend from './components/MapLegend';
|
||||
import NationPanel from './components/NationPanel';
|
||||
@@ -46,13 +44,20 @@ function toastError(e: unknown): void {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating-window collapse persistence (positions live in FloatingWindow itself).
|
||||
// Floating-window collapse + dock persistence.
|
||||
// (Free-floating *positions* live in FloatingWindow itself, per id.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed';
|
||||
const FLOAT_DOCK_KEY = 'history.floatwin.v1.dock';
|
||||
|
||||
/** Every collapsible HUD panel (the slim header is never collapsed). */
|
||||
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend', 'zoom'] as const;
|
||||
/**
|
||||
* Every collapsible HUD panel (the slim, pinned header is never collapsed).
|
||||
* The three substantive panels (dossier / feed / action) are also dockable;
|
||||
* the legend stays a small floating widget, and there is no longer a "Map"
|
||||
* window.
|
||||
*/
|
||||
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend'] as const;
|
||||
|
||||
function loadCollapsedMap(): Record<string, boolean> {
|
||||
try {
|
||||
@@ -78,6 +83,33 @@ function persistCollapsedMap(m: Record<string, boolean>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A dock mode: a docked edge, or 'float' (free-floating, the default). */
|
||||
type DockMode = 'left' | 'right' | 'float';
|
||||
|
||||
function loadDockMap(): Record<string, DockMode> {
|
||||
try {
|
||||
const raw = localStorage.getItem(FLOAT_DOCK_KEY);
|
||||
if (raw == null) return {};
|
||||
const v: unknown = JSON.parse(raw);
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
|
||||
const out: Record<string, DockMode> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
if (val === 'left' || val === 'right' || val === 'float') out[k] = val;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function persistDockMap(m: Record<string, DockMode>): void {
|
||||
try {
|
||||
localStorage.setItem(FLOAT_DOCK_KEY, JSON.stringify(m));
|
||||
} catch {
|
||||
/* storage unavailable — dock choices just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active-game persistence. The id of the game the user is (or was last) playing
|
||||
// is remembered in localStorage, so a page refresh drops them straight back
|
||||
@@ -111,6 +143,28 @@ function clearActiveGameId(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hash routing. 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 manual hash sync — no react-router, no server-side route — fits the
|
||||
// existing state-based router and is static/Traefik safe, because the fragment
|
||||
// never reaches the server (every non-API GET just serves `index.html`). The
|
||||
// localStorage active-game id above stays as the fallback for a bare `/` load;
|
||||
// the URL hash takes precedence when present.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The game id encoded in `#/game/<id>`, or `null` when the hash is home/bare. */
|
||||
function hashGameId(hash: string): string | null {
|
||||
const m = hash.match(/^#\/game\/(.+)$/);
|
||||
return m && m[1].length > 0 ? m[1] : null;
|
||||
}
|
||||
|
||||
/** Write the home route (`#/`) into the URL hash unless it is already home/bare. */
|
||||
function setHomeHash(): void {
|
||||
const h = window.location.hash;
|
||||
if (h !== '' && h !== '#/') window.location.hash = '#/';
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [screen, setScreen] = useState<Screen>({ name: 'home' });
|
||||
const [presets, setPresets] = useState<PresetMeta[]>([]);
|
||||
@@ -119,10 +173,41 @@ export default function App() {
|
||||
const [regions, setRegions] = useState<RegionMap>({});
|
||||
const [advisors, setAdvisors] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Separate from `busy`: an advisor fetch is its own LLM call. It must NOT
|
||||
// gate the turn flow ("Jump forward" stays enabled while the advisor thinks) —
|
||||
// it only gates the advisor UI (the "Ask advisor" button + its hint).
|
||||
const [advisorBusy, setAdvisorBusy] = useState(false);
|
||||
// The id of the game the user is/was last in (mirrors localStorage) and the
|
||||
// list of saved games (newest first) shown on the home screen.
|
||||
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
||||
const [savedGames, setSavedGames] = useState<GameSummary[]>([]);
|
||||
// Once the boot fetch resolves (success or fallback), allow the URL-sync effect
|
||||
// to start writing the hash. Until then, a deep link in the URL must not be
|
||||
// clobbered by an app-driven "home" write during the initial render.
|
||||
const bootedRef = useRef(false);
|
||||
// Latest game + handlers, read by the (mount-once) hashchange listener so it
|
||||
// never acts on a stale closure. Written each render (same pattern as sse.ts).
|
||||
const latestRef = useRef<{ game: GameState | null; goHome: () => void; loadGame: (id: string) => void } | null>(null);
|
||||
|
||||
// The single entry point for adopting a full game state — SSE `state`/`turn`
|
||||
// events AND the REST responses (add/remove/jump) AND game loads all flow
|
||||
// through here. Two rules:
|
||||
// • a *different* game id (new game, deep link, resume) is always adopted;
|
||||
// • the *same* game is adopted only when its `version` is not older than
|
||||
// the current state's ("never go backwards").
|
||||
// The comparison happens inside the functional updater, so it always reads
|
||||
// the latest committed state — never a stale closure. This is what makes a
|
||||
// late-arriving stale SSE snapshot (e.g. the on-connect resync taken just
|
||||
// before an add was saved, or an EventSource auto-reconnect) harmless: it
|
||||
// carries a lower version than the fresher state the REST response already
|
||||
// applied, so it is dropped instead of clobbering the queued item (#10).
|
||||
// Legacy games saved before `version` existed are treated as version 0.
|
||||
const applyState = useCallback((s: GameState) => {
|
||||
setGame((cur) => {
|
||||
if (!cur || cur.id !== s.id) return s;
|
||||
return (s.version ?? 0) >= (cur.version ?? 0) ? s : cur;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// On the home screen: load the scenario manifest and the list of saved games
|
||||
// (newest-updated first) so the user can resume a prior game.
|
||||
@@ -142,28 +227,36 @@ export default function App() {
|
||||
};
|
||||
}, [screen.name]);
|
||||
|
||||
// On first mount, auto-resume the last active game (if any is stored): fetch
|
||||
// its persisted state, remember it, and drop the user straight back into the
|
||||
// game screen. The map is refetched by the effect below (keyed on `game`) and
|
||||
// the `useSse(game?.id)` effect re-attaches the stream, resyncing from the
|
||||
// server on connect. If the id is stale (404 / deleted), clear it and fall
|
||||
// back to the home screen gracefully.
|
||||
// On first mount, decide which game to show and load it. A deep link in the
|
||||
// URL (`#/game/<id>`) takes precedence over the localStorage auto-resume; a
|
||||
// bare page (no hash) falls back to the stored active game. The map is
|
||||
// refetched by the effect below (keyed on `game`) and `useSse(game?.id)`
|
||||
// re-attaches the stream, resyncing from the server on connect. A bad or
|
||||
// missing id (404) must never crash: we clear the hash to `#/`, drop any
|
||||
// stale stored id, and stay on the home screen. Once boot resolves (success
|
||||
// or fallback) `bootedRef` flips so the URL-sync effect may start writing.
|
||||
useEffect(() => {
|
||||
const id = readActiveGameId();
|
||||
if (!id) return;
|
||||
const id = hashGameId(window.location.hash) ?? readActiveGameId();
|
||||
if (!id) {
|
||||
bootedRef.current = true; // nothing to load — activate URL syncing
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const { game } = await api.getGame(id);
|
||||
if (!alive) return;
|
||||
persistActiveGameId(id); // remember it so a future bare `/` load resumes it
|
||||
setActiveGameId(id);
|
||||
setGame(game);
|
||||
applyState(game);
|
||||
setScreen({ name: 'game' });
|
||||
} catch {
|
||||
if (alive) {
|
||||
clearActiveGameId();
|
||||
setActiveGameId(null);
|
||||
}
|
||||
if (!alive) return;
|
||||
clearActiveGameId();
|
||||
setActiveGameId(null);
|
||||
setHomeHash(); // bad/missing id → fall back to the home screen
|
||||
} finally {
|
||||
bootedRef.current = true; // boot resolved — activate URL syncing
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -171,6 +264,38 @@ export default function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep the URL in sync with the current screen: a loaded game is
|
||||
// `#/game/<id>`; everything else (home and the pick screens) is `#/`. This is
|
||||
// the single writer of the hash for app-driven transitions (game created,
|
||||
// resumed, or leaving for home). It only writes when the desired hash differs
|
||||
// from the current one, so it never triggers a redundant `hashchange`.
|
||||
useEffect(() => {
|
||||
if (!bootedRef.current) return;
|
||||
const desired = screen.name === 'game' && game ? `#/game/${game.id}` : '#/';
|
||||
if (window.location.hash !== desired) window.location.hash = desired;
|
||||
}, [screen.name, game?.id]);
|
||||
|
||||
// React to user-driven hash changes: manual edits and browser back/forward.
|
||||
// Only re-act when the parsed id actually differs from the loaded game (or the
|
||||
// hash went home while we're in a game), so the writes above never loop. Uses
|
||||
// `latestRef` so the mount-once effect always sees the current game + handlers.
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const cur = latestRef.current;
|
||||
if (!cur) return;
|
||||
const id = hashGameId(window.location.hash);
|
||||
if (id) {
|
||||
if (id === cur.game?.id) return; // already showing this game — no-op
|
||||
void cur.loadGame(id);
|
||||
} else if (cur.game) {
|
||||
cur.goHome(); // hash is home but we're in a game — return home
|
||||
}
|
||||
// else: hash is home and we're already on home/pick — nothing to do
|
||||
};
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
// Keep the choropleth in sync with whatever state is shown (territory changes).
|
||||
useEffect(() => {
|
||||
if (!game) return;
|
||||
@@ -181,10 +306,15 @@ export default function App() {
|
||||
}, [game]);
|
||||
|
||||
// Resync from the persisted server state on connect and after every turn.
|
||||
// Handlers only call the stable setGame, so no stale-closure concerns.
|
||||
useSse(game?.id ?? null, {
|
||||
onState: (s) => setGame(s),
|
||||
onTurn: (s) => setGame(s),
|
||||
onState: applyState,
|
||||
onTurn: applyState,
|
||||
// The game disappeared while this tab had it open — either deleted from
|
||||
// another tab (the server broadcasts an SSE `error`) or a stale deep link to
|
||||
// a missing game (the SSE connect emits `error`). Return this tab home.
|
||||
onError: (msg) => {
|
||||
if (/deleted|not found/i.test(msg)) latestRef.current?.goHome();
|
||||
},
|
||||
});
|
||||
|
||||
const play = useCallback(async (presetId: string) => {
|
||||
@@ -213,7 +343,7 @@ export default function App() {
|
||||
// Remember this as the active game so a refresh resumes it.
|
||||
persistActiveGameId(game.id);
|
||||
setActiveGameId(game.id);
|
||||
setGame(game);
|
||||
applyState(game);
|
||||
setAdvisors([]);
|
||||
setScreen({ name: 'game' });
|
||||
} catch (e) {
|
||||
@@ -225,78 +355,105 @@ export default function App() {
|
||||
[screen],
|
||||
);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (action: string) => {
|
||||
// Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
|
||||
// list updates from the response and the SSE `state` event. Both are
|
||||
// version-gated via applyState, so the item can never be clobbered by a
|
||||
// stale snapshot (#10).
|
||||
const runAddAction = useCallback(
|
||||
async (text: string) => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.turn(game.id, action);
|
||||
setGame(state);
|
||||
const { game: g } = await api.addAction(game.id, text);
|
||||
applyState(g);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[game],
|
||||
[game, applyState],
|
||||
);
|
||||
|
||||
const runChat = useCallback(
|
||||
// Add a diplomatic message to the pending round. Fast, non-LLM — no `busy`.
|
||||
const runAddDiplomacy = useCallback(
|
||||
async (to: string, message: string) => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.turn(game.id, undefined, { to, message });
|
||||
setGame(state);
|
||||
const { game: g } = await api.addDiplomacy(game.id, to, message);
|
||||
applyState(g);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[game],
|
||||
[game, applyState],
|
||||
);
|
||||
|
||||
// Remove a queued item by index. Fast, non-LLM — no `busy`; the pending list
|
||||
// updates from the response and the SSE `state` event (version-gated).
|
||||
const runRemoveQueueItem = useCallback(
|
||||
async (index: number) => {
|
||||
if (!game) return;
|
||||
try {
|
||||
const { game: g } = await api.removeQueueItem(game.id, index);
|
||||
applyState(g);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
},
|
||||
[game, applyState],
|
||||
);
|
||||
|
||||
// Jump forward: the single LLM call that commits the pending round. Only this
|
||||
// sets `busy` (the adds above are fast and non-LLM).
|
||||
const runJump = useCallback(async () => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.jump(game.id);
|
||||
setGame(state);
|
||||
applyState(state);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [game]);
|
||||
}, [game, applyState]);
|
||||
|
||||
// Ask the advisor: a separate (slow) LLM call. It sets its own `advisorBusy`
|
||||
// flag (distinct from the turn `busy`) and clears it in `finally`, so the flag
|
||||
// is released on both success and error. Asking never advances a turn and
|
||||
// never touches the turn `busy` state — "Jump forward" stays usable.
|
||||
const askAdvisor = useCallback(
|
||||
async (prompt?: string) => {
|
||||
if (!game) return;
|
||||
setAdvisorBusy(true);
|
||||
try {
|
||||
const { suggestions } = await api.advisor(game.id, prompt);
|
||||
setAdvisors(suggestions);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
setAdvisorBusy(false);
|
||||
}
|
||||
},
|
||||
[game],
|
||||
);
|
||||
|
||||
const resumeGame = useCallback(async (id: string) => {
|
||||
// Load (fetch + show) a saved game by id. Shared by the home screen's resume
|
||||
// affordances and by the hash router when the URL points at a different game.
|
||||
// On failure (deleted / 404) it never throws: it clears the stale reference,
|
||||
// clears the hash to `#/`, and stays on the home screen.
|
||||
const loadGame = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { game } = await api.getGame(id);
|
||||
// Make this the active game again so a refresh keeps resuming it.
|
||||
// Make this the active game so a bare `/` reload keeps resuming it.
|
||||
persistActiveGameId(id);
|
||||
setActiveGameId(id);
|
||||
setGame(game);
|
||||
applyState(game);
|
||||
setAdvisors([]);
|
||||
setScreen({ name: 'game' });
|
||||
} catch (e) {
|
||||
// Deleted / not found: clear any stale reference and stay on home.
|
||||
clearActiveGameId();
|
||||
setActiveGameId(null);
|
||||
setHomeHash();
|
||||
setScreen({ name: 'home' });
|
||||
toastError(e);
|
||||
} finally {
|
||||
@@ -304,6 +461,9 @@ export default function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The home screen's "resume" affordances just load a saved game by id.
|
||||
const resumeGame = useCallback((id: string) => void loadGame(id), [loadGame]);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
setScreen({ name: 'home' });
|
||||
setGame(null);
|
||||
@@ -311,6 +471,32 @@ export default function App() {
|
||||
setAdvisors([]);
|
||||
}, []);
|
||||
|
||||
// Delete a saved game. Removes it from the "Saved games" list, clears it if it
|
||||
// was the remembered active game, and heads home if it's the one currently
|
||||
// shown. The server also broadcasts an SSE `error` to any *other* open tab of
|
||||
// this game, which the `useSse` `onError` handler above turns into a go-home.
|
||||
const runDeleteGame = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await api.deleteGame(id);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
return;
|
||||
}
|
||||
setSavedGames((prev) => prev.filter((g) => g.id !== id));
|
||||
if (activeGameId === id) {
|
||||
clearActiveGameId();
|
||||
setActiveGameId(null);
|
||||
}
|
||||
if (game?.id === id) goHome();
|
||||
toast.success('Game deleted');
|
||||
},
|
||||
[activeGameId, game, goHome],
|
||||
);
|
||||
|
||||
// Expose the latest game + handlers to the mount-once hashchange listener.
|
||||
latestRef.current = { game, goHome, loadGame };
|
||||
|
||||
if (screen.name === 'game' && game) {
|
||||
return (
|
||||
<>
|
||||
@@ -320,9 +506,11 @@ export default function App() {
|
||||
game={game}
|
||||
regions={regions}
|
||||
busy={busy}
|
||||
advisorBusy={advisorBusy}
|
||||
advisors={advisors}
|
||||
onAction={runAction}
|
||||
onChat={runChat}
|
||||
onAddAction={runAddAction}
|
||||
onAddDiplomacy={runAddDiplomacy}
|
||||
onRemoveQueueItem={runRemoveQueueItem}
|
||||
onAdvisor={askAdvisor}
|
||||
onJump={runJump}
|
||||
onHome={goHome}
|
||||
@@ -350,6 +538,7 @@ export default function App() {
|
||||
savedGames={savedGames}
|
||||
activeGameId={activeGameId}
|
||||
onResume={resumeGame}
|
||||
onDelete={runDeleteGame}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -358,10 +547,14 @@ export default function App() {
|
||||
interface GameScreenProps {
|
||||
game: GameState;
|
||||
regions: RegionMap;
|
||||
/** A turn ("Jump forward") is in flight — drives the turn flow + commit button. */
|
||||
busy: boolean;
|
||||
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
|
||||
advisorBusy: boolean;
|
||||
advisors: string[];
|
||||
onAction: (a: string) => void;
|
||||
onChat: (to: string, m: string) => void;
|
||||
onAddAction: (a: string) => void;
|
||||
onAddDiplomacy: (to: string, m: string) => void;
|
||||
onRemoveQueueItem: (index: number) => void;
|
||||
onAdvisor: (p?: string) => void;
|
||||
onJump: () => void;
|
||||
onHome: () => void;
|
||||
@@ -369,22 +562,27 @@ interface GameScreenProps {
|
||||
|
||||
/**
|
||||
* The main play view — a full-bleed world map with a floating HUD. The MapLibre
|
||||
* map is a fixed, full-viewport background (z-0); over it float draggable,
|
||||
* collapsible windows: a slim header bar (scenario, turn + date, player nation,
|
||||
* live status, "Focus map" collapse-all) at the top, the nation dossier
|
||||
* (top-left), the event feed (right), the "Your move" command deck
|
||||
* (bottom-center), the nation-color legend (bottom-left), and a tiny map zoom
|
||||
* control (bottom-right). Each window remembers its dragged position, and
|
||||
* clicking one raises it above its siblings. Pan/zoom/click on the map stays
|
||||
* fully available in the gaps between windows.
|
||||
* map is a fixed, full-viewport background (z-0) that the user drives directly
|
||||
* (wheel/trackpad zoom + drag pan — there is no floating zoom control). Over it:
|
||||
* a slim **pinned** header bar (fixed, non-movable, centered at the top a few
|
||||
* px down; scenario, turn + date, player nation, live status, "Focus map"
|
||||
* collapse-all); the nation dossier, the event feed, and the "Your move"
|
||||
* command deck — each free-floating by default (at the same first-run positions
|
||||
* as before) but **dockable** into a full-height left or right pane, the choice
|
||||
* being remembered; and the nation-color legend, which stays a small floating
|
||||
* widget (not dockable). Each floating window remembers its dragged position,
|
||||
* and clicking one raises it above its siblings. Pan/zoom/click on the map
|
||||
* stays fully available in the gaps between windows.
|
||||
*/
|
||||
function GameScreen({
|
||||
game,
|
||||
regions,
|
||||
busy,
|
||||
advisorBusy,
|
||||
advisors,
|
||||
onAction,
|
||||
onChat,
|
||||
onAddAction,
|
||||
onAddDiplomacy,
|
||||
onRemoveQueueItem,
|
||||
onAdvisor,
|
||||
onJump,
|
||||
onHome,
|
||||
@@ -417,11 +615,23 @@ function GameScreen({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Tiny map API (zoom in/out) exposed by WorldMap for the floating control.
|
||||
const mapApiRef = useRef<MapApi | null>(null);
|
||||
const handleMapReady = useCallback((api: MapApi | null) => {
|
||||
mapApiRef.current = api;
|
||||
// Dock state for the three dockable panels (dossier / feed / action),
|
||||
// persisted per id (survives reloads). The default is 'float' — the current
|
||||
// first-run positions — so nothing visually changes until the user docks
|
||||
// something. The legend is not dockable; the header is pinned.
|
||||
const [dock, setDock] = useState<Record<string, DockMode>>(loadDockMap);
|
||||
const setDockFor = useCallback((id: string, mode: DockMode) => {
|
||||
setDock((prev) => {
|
||||
const next = { ...prev, [id]: mode };
|
||||
persistDockMap(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// The `dock` prop expects a concrete edge, or `undefined` to free-float.
|
||||
const dockOf = (id: string) => {
|
||||
const m = dock[id];
|
||||
return m === 'left' || m === 'right' ? m : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
@@ -432,13 +642,14 @@ function GameScreen({
|
||||
nations={game.nations}
|
||||
presetId={game.presetId}
|
||||
onFocusNation={setViewingId}
|
||||
onMapReady={handleMapReady}
|
||||
/>
|
||||
|
||||
{/* Slim floating header: brand, turn + date, player nation, live status, Focus map. */}
|
||||
{/* Pinned top bar: brand, turn + date, player nation, live status, Focus map.
|
||||
Fixed and non-movable, centered at the top a few px from the top edge. */}
|
||||
<FloatingWindow
|
||||
id="header"
|
||||
anchor="top-center"
|
||||
dataTestId="top-bar"
|
||||
pinned
|
||||
collapsible={false}
|
||||
title={
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
@@ -485,17 +696,24 @@ function GameScreen({
|
||||
{player?.name ?? game.playerNationId}
|
||||
</span>
|
||||
</div>
|
||||
{/* Three-state live status: advisor fetch in flight → "Advisor
|
||||
thinking…"; else a turn in flight → "Simulating…"; else "Idle".
|
||||
Both LLM-in-flight states share the amber spinner; idle is muted. */}
|
||||
<span
|
||||
data-testid="status-indicator"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||
busy
|
||||
advisorBusy || busy
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border bg-muted/40 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{busy ? <Loader2 className="size-3 animate-spin" /> : <CircleDot className="size-3" />}
|
||||
{busy ? 'Simulating…' : 'Idle'}
|
||||
{advisorBusy || busy ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<CircleDot className="size-3" />
|
||||
)}
|
||||
{advisorBusy ? 'Advisor thinking…' : busy ? 'Simulating…' : 'Idle'}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -511,13 +729,16 @@ function GameScreen({
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Nation dossier, top-left. */}
|
||||
{/* Nation dossier, top-left by default; dockable into a left/right pane. */}
|
||||
<FloatingWindow
|
||||
id="dossier"
|
||||
dataTestId="panel-dossier"
|
||||
anchor="top-left"
|
||||
width={300}
|
||||
title="Dossier"
|
||||
titleIcon={<BookOpen className="size-4" />}
|
||||
dock={dockOf('dossier')}
|
||||
onDockChange={(m) => setDockFor('dossier', m)}
|
||||
collapsed={!!collapsed.dossier}
|
||||
onCollapsedChange={(c) => setCollapsedFor('dossier', c)}
|
||||
bodyClassName="max-h-[56vh] overflow-y-auto"
|
||||
@@ -542,9 +763,10 @@ function GameScreen({
|
||||
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Event feed, right side. */}
|
||||
{/* Event feed, right side by default; dockable into a left/right pane. */}
|
||||
<FloatingWindow
|
||||
id="feed"
|
||||
dataTestId="panel-feed"
|
||||
anchor="right"
|
||||
width={330}
|
||||
title="Event feed"
|
||||
@@ -554,6 +776,8 @@ function GameScreen({
|
||||
{game.events.length}
|
||||
</Badge>
|
||||
}
|
||||
dock={dockOf('feed')}
|
||||
onDockChange={(m) => setDockFor('feed', m)}
|
||||
collapsed={!!collapsed.feed}
|
||||
onCollapsedChange={(c) => setCollapsedFor('feed', c)}
|
||||
bodyClassName="h-[52vh]"
|
||||
@@ -561,25 +785,23 @@ function GameScreen({
|
||||
<EventFeed events={game.events} nations={game.nations} inWindow />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Command deck ("Your move"), bottom-center — the prominent typing spot. */}
|
||||
{/* Command deck ("Your move"), bottom-center by default; dockable. */}
|
||||
<FloatingWindow
|
||||
id="action"
|
||||
dataTestId="panel-action"
|
||||
anchor="bottom-center"
|
||||
width={370}
|
||||
title="Your move"
|
||||
titleIcon={<Radar className="size-4 text-primary" />}
|
||||
titleExtra={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onJump}
|
||||
disabled={busy}
|
||||
data-testid="jump-forward"
|
||||
className="shrink-0"
|
||||
>
|
||||
{busy ? 'Simulating…' : 'Jump forward'}
|
||||
</Button>
|
||||
game.pending.length > 0 ? (
|
||||
<Badge variant="secondary" className="shrink-0 text-xs tabular-nums" data-testid="title-queued">
|
||||
{game.pending.length} queued
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
dock={dockOf('action')}
|
||||
onDockChange={(m) => setDockFor('action', m)}
|
||||
collapsed={!!collapsed.action}
|
||||
onCollapsedChange={(c) => setCollapsedFor('action', c)}
|
||||
bodyClassName="max-h-[72vh] overflow-y-auto"
|
||||
@@ -587,9 +809,11 @@ function GameScreen({
|
||||
<ActionPanel
|
||||
game={game}
|
||||
busy={busy}
|
||||
advisorBusy={advisorBusy}
|
||||
advisors={advisors}
|
||||
onAction={onAction}
|
||||
onChat={onChat}
|
||||
onAddAction={onAddAction}
|
||||
onAddDiplomacy={onAddDiplomacy}
|
||||
onRemoveQueueItem={onRemoveQueueItem}
|
||||
onAdvisor={onAdvisor}
|
||||
onJump={onJump}
|
||||
inWindow
|
||||
@@ -608,39 +832,6 @@ function GameScreen({
|
||||
>
|
||||
<MapLegend regions={regions} />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Tiny floating zoom control, bottom-right (clear of the attribution). */}
|
||||
<FloatingWindow
|
||||
id="zoom"
|
||||
anchor="bottom-right"
|
||||
width={88}
|
||||
title="Map"
|
||||
titleIcon={<MapIcon className="size-4" />}
|
||||
offset={{ y: -28 }}
|
||||
collapsed={!!collapsed.zoom}
|
||||
onCollapsedChange={(c) => setCollapsedFor('zoom', c)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5 p-2.5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => mapApiRef.current?.zoomIn()}
|
||||
aria-label="Zoom in"
|
||||
data-testid="zoom-in"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => mapApiRef.current?.zoomOut()}
|
||||
aria-label="Zoom out"
|
||||
data-testid="zoom-out"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+187
-51
@@ -1,45 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Radar, Send } from 'lucide-react';
|
||||
import { Loader2, MessageSquare, Radar, Trash2, Zap } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import type { GameState } from '@shared/types';
|
||||
import type { GameState, RoundItem } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
game: GameState;
|
||||
/** A turn is currently resolving (action/chat/jump) — disable those controls. */
|
||||
/** A jump (the only turn LLM call) is currently in flight — disable the commit. */
|
||||
busy: boolean;
|
||||
/**
|
||||
* An advisor fetch (a separate LLM call) is in flight. Distinct from `busy`:
|
||||
* it gates only the advisor UI (the "Ask advisor" button + its hint) and must
|
||||
* never disable "Jump forward" or the rest of the turn flow.
|
||||
*/
|
||||
advisorBusy: boolean;
|
||||
/** Advisor brainstorm suggestions from the last request. */
|
||||
advisors: string[];
|
||||
onAction: (action: string) => void;
|
||||
onChat: (to: string, message: string) => void;
|
||||
/** Queue a free-text action onto the pending round (no turn advance). */
|
||||
onAddAction: (text: string) => void;
|
||||
/** Queue a diplomatic message onto the pending round (no turn advance). */
|
||||
onAddDiplomacy: (to: string, message: string) => void;
|
||||
/** Remove one queued item by its index in `game.pending` (no turn advance). */
|
||||
onRemoveQueueItem: (index: number) => void;
|
||||
onAdvisor: (prompt?: string) => void;
|
||||
/** Commit the whole pending round in a single LLM turn. */
|
||||
onJump: () => void;
|
||||
/**
|
||||
* Render without the outer card chrome (the surrounding floating window
|
||||
* provides the border, the "Your move" title, and the "Jump forward" button
|
||||
* in its title bar). Same content, same testids.
|
||||
* provides the border and the "Your move" title). Same content, same testids.
|
||||
*/
|
||||
inWindow?: boolean;
|
||||
}
|
||||
|
||||
/** One-line, display-safe rendering of a queued item (text truncated). */
|
||||
function describeItem(item: RoundItem, nationName: (id?: string) => string): string {
|
||||
const who = item.kind === 'diplomacy' ? ` to ${nationName(item.to)}` : '';
|
||||
const text = item.text.length > 72 ? `${item.text.slice(0, 71)}…` : item.text;
|
||||
return `${who ? `${who}:` : 'Action:'} “${text}”`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's command deck ("Your move"). Three tabs — Action (free-text move),
|
||||
* Diplomacy (a message to a specific nation), Advisor (ask the LLM for ideas,
|
||||
* "Use" feeds one into the Action tab) — share one prominent Send button that
|
||||
* routes to the active tab. While a turn resolves it shows a spinner + a hint
|
||||
* that turns take a few seconds. A "Jump forward" button auto-simulates the
|
||||
* world with no player move.
|
||||
* The player's command deck ("Your move"). Three tabs — Action, Diplomacy,
|
||||
* Advisor — each with a small "Add" button that queues an item onto the pending
|
||||
* round (these never advance the turn). Below the tabs a visible pending-queue
|
||||
* list — each row has a small trash button that removes that item (with a
|
||||
* confirmation dialog) — and one prominent "Jump forward" button that commits
|
||||
* the whole round in a single LLM turn. While a jump is in flight the commit
|
||||
* is disabled (as is queue editing) and a hint is shown; an empty jump
|
||||
* (nothing queued) is allowed and advances time.
|
||||
*/
|
||||
export default function ActionPanel({
|
||||
game,
|
||||
busy,
|
||||
advisorBusy,
|
||||
advisors,
|
||||
onAction,
|
||||
onChat,
|
||||
onAddAction,
|
||||
onAddDiplomacy,
|
||||
onRemoveQueueItem,
|
||||
onAdvisor,
|
||||
onJump,
|
||||
inWindow = false,
|
||||
@@ -49,23 +79,27 @@ export default function ActionPanel({
|
||||
const [target, setTarget] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
// Index of the queued item the user is about to remove, or null (dialog closed).
|
||||
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
|
||||
|
||||
const others = Object.entries(game.nations)
|
||||
.filter(([id]) => id !== game.playerNationId)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const submitAction = () => {
|
||||
const nationName = (id?: string) => (id ? (game.nations[id]?.name ?? id) : '');
|
||||
|
||||
const queueAction = () => {
|
||||
const text = action.trim();
|
||||
if (!text || busy) return;
|
||||
onAddAction(text);
|
||||
setAction('');
|
||||
onAction(text);
|
||||
};
|
||||
|
||||
const submitChat = () => {
|
||||
const queueDiplomacy = () => {
|
||||
const msg = message.trim();
|
||||
if (!msg || !target || busy) return;
|
||||
onAddDiplomacy(target, msg);
|
||||
setMessage('');
|
||||
onChat(target, msg);
|
||||
};
|
||||
|
||||
const applyIdea = (idea: string) => {
|
||||
@@ -73,41 +107,21 @@ export default function ActionPanel({
|
||||
setTab('action');
|
||||
};
|
||||
|
||||
// The single Send button routes to the active tab.
|
||||
const sendDisabled =
|
||||
busy ||
|
||||
(tab === 'action'
|
||||
? !action.trim()
|
||||
: tab === 'diplo'
|
||||
? !message.trim() || !target
|
||||
: false);
|
||||
|
||||
const sendLabel = busy
|
||||
const pending = game.pending;
|
||||
// The item the confirmation dialog is about (re-derived from the live list,
|
||||
// so if the queue changes while the dialog is open it tracks the current
|
||||
// item at that index — or the dialog closes if the index went out of range).
|
||||
const removeTarget = confirmRemoveIndex !== null ? pending[confirmRemoveIndex] : undefined;
|
||||
const queuedLabel = busy
|
||||
? 'Simulating…'
|
||||
: tab === 'action'
|
||||
? 'Take action'
|
||||
: tab === 'diplo'
|
||||
? 'Send message'
|
||||
: 'Ask the advisor';
|
||||
: `Jump forward${pending.length > 0 ? ` · ${pending.length} queued` : ''}`;
|
||||
|
||||
const send = () => {
|
||||
if (busy) return;
|
||||
if (tab === 'action') submitAction();
|
||||
else if (tab === 'diplo') submitChat();
|
||||
else onAdvisor(prompt.trim() || undefined);
|
||||
};
|
||||
|
||||
// In window mode the surrounding FloatingWindow shows "Your move" and the
|
||||
// "Jump forward" button in its own title bar, so omit both here.
|
||||
const header = inWindow ? null : (
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radar className="size-4 text-primary" />
|
||||
<CardTitle className="text-base">Your move</CardTitle>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
||||
{busy ? 'Simulating…' : 'Jump forward'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
);
|
||||
|
||||
@@ -134,6 +148,15 @@ export default function ActionPanel({
|
||||
rows={3}
|
||||
data-testid="action-textarea"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={queueAction}
|
||||
disabled={busy || !action.trim()}
|
||||
data-testid="add-action"
|
||||
>
|
||||
Add action
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="diplo" className="space-y-2">
|
||||
@@ -156,6 +179,15 @@ export default function ActionPanel({
|
||||
rows={3}
|
||||
data-testid="chat-message"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={queueDiplomacy}
|
||||
disabled={busy || !message.trim() || !target}
|
||||
data-testid="add-diplomacy"
|
||||
>
|
||||
Add to round
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advisor" className="space-y-2">
|
||||
@@ -165,6 +197,27 @@ export default function ActionPanel({
|
||||
placeholder="Optional focus — e.g. “the nuclear standoff”"
|
||||
data-testid="advisor-prompt"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onAdvisor(prompt.trim() || undefined)}
|
||||
disabled={advisorBusy}
|
||||
data-testid="ask-advisor"
|
||||
>
|
||||
{advisorBusy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{advisorBusy ? 'Thinking…' : 'Ask advisor'}
|
||||
</Button>
|
||||
{/* While the advisor (a separate LLM call) is thinking, mirror the
|
||||
turn's "sim-hint" so the user knows the request is in flight. */}
|
||||
{advisorBusy && (
|
||||
<p
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
data-testid="advisor-hint"
|
||||
>
|
||||
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
||||
The advisor is thinking… this can take a few seconds.
|
||||
</p>
|
||||
)}
|
||||
{advisors.length > 0 && (
|
||||
<ul className="space-y-1.5" data-testid="advisor-list">
|
||||
{advisors.map((a, i) => (
|
||||
@@ -184,14 +237,97 @@ export default function ActionPanel({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Pending queue + the prominent commit — always visible, in any mode. */}
|
||||
<div className="mt-3 space-y-2">
|
||||
<Button className="h-11 w-full" onClick={send} disabled={sendDisabled} data-testid="action-submit">
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-2.5" data-testid="pending-list">
|
||||
{pending.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground" data-testid="pending-empty">
|
||||
Nothing queued — add an action or a message, then jump forward.
|
||||
</p>
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
<>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Queued this round
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[11px] tabular-nums">
|
||||
{pending.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{pending.map((item, i) => (
|
||||
<li
|
||||
key={`${i}-${item.text}`}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
data-testid="pending-item"
|
||||
data-kind={item.kind}
|
||||
>
|
||||
{item.kind === 'action' ? (
|
||||
<Zap className="size-3.5 shrink-0 text-amber-400" aria-hidden />
|
||||
) : (
|
||||
<MessageSquare className="size-3.5 shrink-0 text-cyan-400" aria-hidden />
|
||||
)}
|
||||
{item.kind === 'diplomacy' && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
→ {nationName(item.to)}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/85">{item.text}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setConfirmRemoveIndex(i)}
|
||||
disabled={busy}
|
||||
title="Remove from this round"
|
||||
aria-label="Remove this queued item"
|
||||
data-testid="remove-queue-item"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{sendLabel}
|
||||
</div>
|
||||
|
||||
{/* Confirmation for removing a queued item (never removes without asking). */}
|
||||
<Dialog open={removeTarget !== undefined} onOpenChange={(open) => !open && setConfirmRemoveIndex(null)}>
|
||||
<DialogContent className="max-w-sm" data-testid="remove-confirm-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove queued item?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{removeTarget ? (
|
||||
<>
|
||||
{describeItem(removeTarget, nationName)} will be removed from this round and
|
||||
won’t be simulated on the next jump.
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmRemoveIndex(null)} data-testid="remove-cancel">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (confirmRemoveIndex !== null) onRemoveQueueItem(confirmRemoveIndex);
|
||||
setConfirmRemoveIndex(null);
|
||||
}}
|
||||
data-testid="remove-confirm"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
|
||||
{queuedLabel}
|
||||
</Button>
|
||||
{busy && (
|
||||
<p
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
import { AlignLeft, AlignRight, Maximize2, Minus, Plus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Edge anchor used to place a window on first run (before the user drags it). */
|
||||
@@ -19,6 +19,11 @@ export type FloatAnchor =
|
||||
| 'bottom-center'
|
||||
| 'bottom-right';
|
||||
|
||||
/** A docked column edge: pinned to a screen edge and filling the viewport height. */
|
||||
export type FloatDock = 'left' | 'right';
|
||||
/** A dock mode: a docked edge, or `'float'` (free-floating, the default). */
|
||||
export type FloatDockMode = FloatDock | 'float';
|
||||
|
||||
const STORAGE_PREFIX = 'history.floatwin.v1.';
|
||||
|
||||
// Module-level z-index bookkeeping so sibling windows stack in click order:
|
||||
@@ -26,6 +31,9 @@ const STORAGE_PREFIX = 'history.floatwin.v1.';
|
||||
// raised, the shared counter advances, so the newest on top always wins.
|
||||
const Z_BASE = 10;
|
||||
const Z_STEP = 10;
|
||||
// A pinned window always floats above every docked/floating window, no matter
|
||||
// how many times those are raised.
|
||||
const Z_PINNED = 100000;
|
||||
let zSeq = 0;
|
||||
let zTopId: string | null = null;
|
||||
/** Returns the new z-index to use, or -1 if this window is already front-most. */
|
||||
@@ -118,8 +126,31 @@ function clampPos(x: number, y: number, w: number, h: number): Pos {
|
||||
export interface FloatingWindowProps {
|
||||
/** Stable id — used for the localStorage key and as a data-attribute. */
|
||||
id: string;
|
||||
/** First-run placement. The last dragged position (persisted) takes over. */
|
||||
anchor: FloatAnchor;
|
||||
/** Optional `data-testid` on the window root, for e2e targeting. */
|
||||
dataTestId?: string;
|
||||
/**
|
||||
* First-run placement for a free-floating window; the last dragged position
|
||||
* (persisted) takes over. Ignored when `pinned` or `dock` is set.
|
||||
*/
|
||||
anchor?: FloatAnchor;
|
||||
/**
|
||||
* Pin the window to the top-center of the viewport: a fixed, non-movable bar
|
||||
* a few px from the top that re-centers automatically on resize, always
|
||||
* floats above docked/floating panels, and never reads/writes a persisted
|
||||
* position. Used for the slim header.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
/**
|
||||
* Dock the window into a full-height left or right column: non-movable,
|
||||
* body scrolls, never reads/writes the persisted free-floating position.
|
||||
* Absent (`undefined`) = free-floating as before.
|
||||
*/
|
||||
dock?: FloatDock;
|
||||
/**
|
||||
* When provided, render dock controls (left / right / float) in the title bar
|
||||
* and report the chosen mode back to the parent, which persists it.
|
||||
*/
|
||||
onDockChange?: (mode: FloatDockMode) => void;
|
||||
/** Fixed window width in px (content is sized to it). Omit to fit content. */
|
||||
width?: number;
|
||||
/** Extra px nudged away from the anchor position (e.g. clear the map attribution). */
|
||||
@@ -128,7 +159,7 @@ export interface FloatingWindowProps {
|
||||
title?: ReactNode;
|
||||
/** Optional icon before the title. */
|
||||
titleIcon?: ReactNode;
|
||||
/** Right-side title-bar content, rendered before the collapse button. */
|
||||
/** Right-side title-bar content, rendered before the dock/collapse controls. */
|
||||
titleExtra?: ReactNode;
|
||||
/** Render the collapse toggle (default true). */
|
||||
collapsible?: boolean;
|
||||
@@ -143,16 +174,27 @@ export interface FloatingWindowProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* A draggable, collapsible, position-persisted window that floats over the
|
||||
* full-screen map. Dragging is limited to the title bar (via pointer capture)
|
||||
* so inputs and buttons in the body keep working; the window is clamped so it
|
||||
* can never be dragged fully off-screen, its position is remembered in
|
||||
* localStorage, clicking any part of it raises it above siblings, and the
|
||||
* collapse toggle shrinks it to just its title bar.
|
||||
* A window that floats over the full-screen map. Three mutually-exclusive
|
||||
* modes, set via props (absent = free-floating):
|
||||
* • **free-floating** (default) — draggable by the title bar (pointer
|
||||
* capture), clamped on-screen, position persisted in localStorage, raised
|
||||
* above siblings on click, collapsible.
|
||||
* • **pinned** — a fixed, non-movable bar centered at the top of the
|
||||
* viewport a few px down; re-centers on resize; always above other
|
||||
* windows; no position persisted.
|
||||
* • **docked** (`left`/`right`) — a non-movable, full-height column pinned
|
||||
* to a screen edge whose body scrolls; the persisted free-floating
|
||||
* position is neither read nor written while docked.
|
||||
* When `onDockChange` is provided, small dock/left/right/float controls render
|
||||
* in the title bar and report the chosen mode back to the parent.
|
||||
*/
|
||||
export default function FloatingWindow({
|
||||
id,
|
||||
anchor,
|
||||
dataTestId,
|
||||
anchor = 'top-center',
|
||||
pinned = false,
|
||||
dock,
|
||||
onDockChange,
|
||||
width,
|
||||
offset,
|
||||
title,
|
||||
@@ -165,6 +207,12 @@ export default function FloatingWindow({
|
||||
bodyClassName,
|
||||
children,
|
||||
}: FloatingWindowProps) {
|
||||
const isPinned = pinned;
|
||||
const isDocked = dock === 'left' || dock === 'right';
|
||||
// Free-floating is the only mode that drags, persists a position, and needs
|
||||
// viewport measurement/clamping. Pinned and docked are CSS-positioned.
|
||||
const isFloating = !isPinned && !isDocked;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const sizeRef = useRef<{ w: number; h: number } | null>(null);
|
||||
const dragRef = useRef<{ dx: number; dy: number; pointerId: number } | null>(null);
|
||||
@@ -177,9 +225,11 @@ export default function FloatingWindow({
|
||||
setPosState(p);
|
||||
};
|
||||
|
||||
// First paint: measure, then place (restored position or anchor) and clamp.
|
||||
// Runs before paint (useLayoutEffect), so there is no visible flash.
|
||||
// First paint (free-floating only): measure, then place (restored position
|
||||
// or anchor) and clamp. Runs before paint (useLayoutEffect), so no flash.
|
||||
// Re-runs when the mode changes so a window returning from docked re-places.
|
||||
useLayoutEffect(() => {
|
||||
if (!isFloating) return;
|
||||
if (posRef.current) return;
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
@@ -195,11 +245,12 @@ export default function FloatingWindow({
|
||||
);
|
||||
posRef.current = p;
|
||||
setPosState(p);
|
||||
}, [id, anchor]);
|
||||
}, [id, anchor, offset, isFloating]);
|
||||
|
||||
// Re-measure + re-clamp whenever the body toggles hidden (expand can push a
|
||||
// bottom-anchored window off the bottom edge).
|
||||
// bottom-anchored window off the bottom edge). Free-floating only.
|
||||
useLayoutEffect(() => {
|
||||
if (!isFloating) return;
|
||||
const el = rootRef.current;
|
||||
const p = posRef.current;
|
||||
if (!el || !p) return;
|
||||
@@ -208,10 +259,11 @@ export default function FloatingWindow({
|
||||
sizeRef.current = { w: rect.width, h: rect.height };
|
||||
const c = clampPos(p.x, p.y, rect.width, rect.height);
|
||||
if (c.x !== p.x || c.y !== p.y) setPos(c);
|
||||
}, [collapsed]);
|
||||
}, [collapsed, isFloating]);
|
||||
|
||||
// Keep the window on-screen when the viewport shrinks.
|
||||
// Keep a free-floating window on-screen when the viewport shrinks.
|
||||
useEffect(() => {
|
||||
if (!isFloating) return;
|
||||
const onResize = () => {
|
||||
const p = posRef.current;
|
||||
const s = sizeRef.current;
|
||||
@@ -221,7 +273,7 @@ export default function FloatingWindow({
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
}, [isFloating]);
|
||||
|
||||
const onHandleDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -258,18 +310,44 @@ export default function FloatingWindow({
|
||||
if (p) savePos(id, p.x, p.y);
|
||||
};
|
||||
|
||||
const style: CSSProperties = {
|
||||
zIndex: z,
|
||||
...(pos ? { left: pos.x, top: pos.y } : {}),
|
||||
};
|
||||
// Root placement: pinned → centered top bar; docked → full-height edge
|
||||
// column; free-floating → last/initial pixel position (hidden until measured).
|
||||
const rootStyle: CSSProperties = isPinned
|
||||
? { zIndex: Z_PINNED, top: 8, left: '50%', transform: 'translateX(-50%)' }
|
||||
: isDocked
|
||||
? {
|
||||
zIndex: z,
|
||||
top: 0,
|
||||
height: '100vh',
|
||||
...(dock === 'left' ? { left: 0 } : { right: 0 }),
|
||||
}
|
||||
: { zIndex: z, ...(pos ? { left: pos.x, top: pos.y } : {}) };
|
||||
|
||||
// The title bar is the drag handle only in free-floating mode.
|
||||
const titleBarHandlers = isFloating
|
||||
? {
|
||||
onPointerDown: onHandleDown,
|
||||
onPointerMove: onHandleMove,
|
||||
onPointerUp: endDrag,
|
||||
onPointerCancel: endDrag,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Docked: the body fills the remaining column height and scrolls, overriding
|
||||
// any max-height the caller set for the floating layout.
|
||||
const bodyClass = isDocked
|
||||
? cn('min-h-0 flex-1 overflow-y-auto', collapsed && 'hidden')
|
||||
: cn('min-h-0', collapsed && 'hidden', bodyClassName);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-float-window={id}
|
||||
style={style}
|
||||
className={cn('fixed', !pos && 'pointer-events-none opacity-0')}
|
||||
data-testid={dataTestId}
|
||||
style={rootStyle}
|
||||
className={cn('fixed', isFloating && !pos && 'pointer-events-none opacity-0')}
|
||||
onPointerDown={() => {
|
||||
if (isPinned) return;
|
||||
const nz = nextZ(id);
|
||||
if (nz > 0) setZ(nz);
|
||||
}}
|
||||
@@ -277,21 +355,24 @@ export default function FloatingWindow({
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border border-border/70 bg-slate-900/80 shadow-2xl shadow-black/50 backdrop-blur-md',
|
||||
isDocked && 'flex h-full flex-col',
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
width != null
|
||||
? { width, maxWidth: '96vw' }
|
||||
: { width: 'fit-content', maxWidth: '96vw' }
|
||||
isDocked
|
||||
? { width: width ?? 'fit-content', maxWidth: '96vw' }
|
||||
: width != null
|
||||
? { width, maxWidth: '96vw' }
|
||||
: { width: 'fit-content', maxWidth: '96vw' }
|
||||
}
|
||||
>
|
||||
{title != null && (
|
||||
<div
|
||||
className="flex cursor-grab touch-none select-none items-center gap-2 border-b border-border/60 bg-white/[0.03] px-2.5 py-1.5 active:cursor-grabbing"
|
||||
onPointerDown={onHandleDown}
|
||||
onPointerMove={onHandleMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
className={cn(
|
||||
'flex shrink-0 touch-none select-none items-center gap-2 border-b border-border/60 bg-white/[0.03] px-2.5 py-1.5',
|
||||
isFloating && 'cursor-grab active:cursor-grabbing',
|
||||
)}
|
||||
{...titleBarHandlers}
|
||||
>
|
||||
{titleIcon && (
|
||||
<span className="shrink-0 text-muted-foreground" aria-hidden>
|
||||
@@ -302,6 +383,49 @@ export default function FloatingWindow({
|
||||
{title}
|
||||
</span>
|
||||
{titleExtra}
|
||||
{onDockChange && (
|
||||
<span className="flex shrink-0 items-center gap-0.5" data-no-drag>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dock-left"
|
||||
aria-label="Dock to the left edge"
|
||||
title="Dock to the left edge"
|
||||
className={cn(
|
||||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||||
dock === 'left' && 'bg-white/10 text-foreground',
|
||||
)}
|
||||
onClick={() => onDockChange('left')}
|
||||
>
|
||||
<AlignLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dock-right"
|
||||
aria-label="Dock to the right edge"
|
||||
title="Dock to the right edge"
|
||||
className={cn(
|
||||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||||
dock === 'right' && 'bg-white/10 text-foreground',
|
||||
)}
|
||||
onClick={() => onDockChange('right')}
|
||||
>
|
||||
<AlignRight className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dock-float"
|
||||
aria-label="Float the window"
|
||||
title="Float (undock)"
|
||||
className={cn(
|
||||
'rounded-md p-1 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground',
|
||||
!isDocked && 'bg-white/10 text-foreground',
|
||||
)}
|
||||
onClick={() => onDockChange('float')}
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{collapsibleProp && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -315,9 +439,7 @@ export default function FloatingWindow({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children != null && (
|
||||
<div className={cn('min-h-0', collapsed && 'hidden', bodyClassName)}>{children}</div>
|
||||
)}
|
||||
{children != null && <div className={bodyClass}>{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Play } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Play, Trash2 } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import type { PresetMeta } from '@shared/types';
|
||||
import type { GameSummary } from '@shared/protocol';
|
||||
|
||||
@@ -15,12 +24,14 @@ interface Props {
|
||||
activeGameId?: string | null;
|
||||
/** Resume a saved game by id (shared by the resume button and saved-games rows). */
|
||||
onResume?: (id: string) => void;
|
||||
/** Delete a saved game by id (confirmed by a dialog in this component). */
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home screen: a grid of scenario cards (each with a Play button), a prominent
|
||||
* "Resume last game" affordance, and a "Saved games" section listing prior
|
||||
* games so the user can get back into one.
|
||||
* games so the user can get back into one — or delete it.
|
||||
*/
|
||||
export default function PresetPicker({
|
||||
presets,
|
||||
@@ -29,8 +40,14 @@ export default function PresetPicker({
|
||||
savedGames = [],
|
||||
activeGameId,
|
||||
onResume,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const activeGame = activeGameId ? savedGames.find((g) => g.id === activeGameId) : undefined;
|
||||
// The game awaiting a delete confirmation (null = dialog closed).
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const confirmDeleteTarget = confirmDeleteId
|
||||
? savedGames.find((g) => g.id === confirmDeleteId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
@@ -90,7 +107,7 @@ export default function PresetPicker({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Saved games: get back into any prior game. */}
|
||||
{/* Saved games: get back into any prior game (or delete it). */}
|
||||
{savedGames.length > 0 && (
|
||||
<section className="mt-12" data-testid="saved-games">
|
||||
<h2 className="mb-4 text-xl font-bold tracking-tight">Saved games</h2>
|
||||
@@ -100,13 +117,28 @@ export default function PresetPicker({
|
||||
key={g.id}
|
||||
className="flex flex-col"
|
||||
data-testid="saved-game"
|
||||
data-game-id={g.id}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base">{g.presetTitle}</CardTitle>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{g.date}
|
||||
</Badge>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{g.date}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
data-testid="delete-game"
|
||||
aria-label={`Delete ${g.presetTitle}`}
|
||||
title="Delete this game"
|
||||
onClick={() => setConfirmDeleteId(g.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="text-sm">Turn {g.turn}</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -125,6 +157,40 @@ export default function PresetPicker({
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Confirm before permanently deleting a saved game. */}
|
||||
<Dialog open={confirmDeleteId !== null} onOpenChange={(o) => !o && setConfirmDeleteId(null)}>
|
||||
<DialogContent className="max-w-sm" data-testid="delete-game-confirm-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete this game?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirmDeleteTarget
|
||||
? `"${confirmDeleteTarget.presetTitle}" (turn ${confirmDeleteTarget.turn}, ${confirmDeleteTarget.date}) will be permanently removed. This cannot be undone.`
|
||||
: 'This game will be permanently removed.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="delete-game-cancel"
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
data-testid="delete-game-confirm"
|
||||
onClick={() => {
|
||||
const id = confirmDeleteId;
|
||||
setConfirmDeleteId(null);
|
||||
if (id) onDelete?.(id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+63
-72
@@ -8,7 +8,7 @@ import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
// Point it at a Vite-emitted copy of the worker module so it loads in prod.
|
||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
|
||||
maplibregl.setWorkerUrl(workerUrl);
|
||||
import type { RegionMap, RegionOwner } from '@shared/protocol';
|
||||
import { regionOwner, type RegionMap, type RegionOwner } from '@shared/protocol';
|
||||
import type { Nation } from '@shared/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -61,7 +61,7 @@ const INLINE_STYLE: maplibregl.StyleSpecification = {
|
||||
type: 'raster',
|
||||
source: 'terrain',
|
||||
paint: {
|
||||
'raster-opacity': 0.5,
|
||||
'raster-opacity': 0.15,
|
||||
'raster-brightness-max': 0.8,
|
||||
'raster-fade-duration': 200,
|
||||
},
|
||||
@@ -101,21 +101,36 @@ function cameraForPreset(presetId: string): { center: [number, number]; zoom: nu
|
||||
* carries its owner's stable fill color, a subtle stroke, and a flag for the
|
||||
* player's own regions (rendered as a brighter, thicker border by a dedicated
|
||||
* layer). Unknown owners fall back to a faint neutral fill — never an error.
|
||||
*
|
||||
* Crucially, the base GeoJSON feature's top-level `id` (the ISO_A3 code) is
|
||||
* stamped into `properties.regionId`, and the owner's id into `properties.ownerId`.
|
||||
* MapLibre repacks a GeoJSON source's top-level `id` into a numeric index when it
|
||||
* stores it, so a later click/hover event exposes that number on
|
||||
* `e.features[i].id` — the top-level `id` is *not* a reliable lookup key at
|
||||
* interaction time (the root cause of #6). `properties`, however, survive into the
|
||||
* rendered features, so the interaction handlers resolve the owning nation from
|
||||
* `properties.regionId` via the RegionMap (see `regionOwner`).
|
||||
*/
|
||||
function applyRegions(geo: FeatureCollection, regions: RegionMap, playerNationId: string): FeatureCollection {
|
||||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||||
for (const f of out.features) {
|
||||
f.properties = f.properties ?? {};
|
||||
const r = f.id ? regions[f.id as string] : undefined;
|
||||
// The base GeoJSON `id` is the ISO_A3 code; keep it in a property that
|
||||
// survives into the features MapLibre hands back on click/hover.
|
||||
const code = f.id == null ? '' : String(f.id);
|
||||
f.properties.regionId = code;
|
||||
const r = code ? regions[code] : undefined;
|
||||
if (r) {
|
||||
f.properties.fill = r.color;
|
||||
f.properties.owner = r.owner;
|
||||
f.properties.stroke = 'rgba(255,255,255,0.10)';
|
||||
f.properties.ownerId = r.ownerId;
|
||||
f.properties.stroke = 'rgba(255,255,255,0.30)';
|
||||
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
|
||||
} else {
|
||||
f.properties.fill = 'rgba(148,163,184,0.05)';
|
||||
f.properties.stroke = 'rgba(255,255,255,0.06)';
|
||||
f.properties.stroke = 'rgba(255,255,255,0.14)';
|
||||
f.properties.owner = '';
|
||||
f.properties.ownerId = '';
|
||||
f.properties.isPlayer = 0;
|
||||
}
|
||||
}
|
||||
@@ -150,48 +165,20 @@ function hoverHtml(countryName: string, region?: RegionOwner, nation?: Nation):
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Click popup: the full owning-nation profile with all four stats. */
|
||||
function clickHtml(countryName: string, region?: RegionOwner, nation?: Nation): string {
|
||||
if (!region || !nation) {
|
||||
return `<div class="hm-popup"><div class="hm-popup-title">${esc(countryName || 'Unclaimed')}</div><div class="hm-popup-sub">No owner</div></div>`;
|
||||
}
|
||||
const stats = [
|
||||
statRow('Mil', nation.stats.military),
|
||||
statRow('Eco', nation.stats.economy),
|
||||
statRow('Stab', nation.stats.stability),
|
||||
statRow('Pop', nation.stats.population),
|
||||
].join('');
|
||||
const regions = nation.territory.length;
|
||||
return `<div class="hm-popup">
|
||||
<div class="hm-popup-title">${esc(countryName || region.owner)}</div>
|
||||
<div class="hm-popup-owner hm-popup-owner-big"><span class="hm-chip" style="background:${region.color}"></span>${esc(region.owner)}</div>
|
||||
<div class="hm-popup-sub">${regions} region${regions === 1 ? '' : 's'} · click focuses the dossier</div>
|
||||
<div class="hm-stats">${stats}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Minimal map API exposed to the floating zoom control (and any other UI). */
|
||||
export interface MapApi {
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
regions: RegionMap;
|
||||
playerNationId: string;
|
||||
/** All nations, for hover/click stats and color chips. */
|
||||
/** All nations, for the hover tooltip's owner stats and color chips. */
|
||||
nations: Record<string, Nation>;
|
||||
/** The active scenario id, for a sensible initial camera. */
|
||||
presetId: string;
|
||||
className?: string;
|
||||
/** Called when a region is clicked, so the dossier can focus its owner. */
|
||||
onFocusNation?: (id: string) => void;
|
||||
/**
|
||||
* Called with a minimal zoom/pan API once the map is ready (and with `null`
|
||||
* on unmount), so the floating zoom control can drive it.
|
||||
* Called with a region's owning-nation id when that region is clicked, so the
|
||||
* dossier switches to that nation. Not called for unowned regions (the dossier
|
||||
* keeps its current target). See the component doc for the full behavior.
|
||||
*/
|
||||
onMapReady?: (api: MapApi | null) => void;
|
||||
onFocusNation?: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,10 +186,18 @@ interface Props {
|
||||
* nation. Rendered as a fixed, full-viewport background layer (z-0) behind the
|
||||
* floating HUD windows: a dark-navy basemap with subtle terrain, per-nation
|
||||
* stable fills (the choropleth is the star), thin borders, a brighter/thicker
|
||||
* border for the player, hover + click popups with
|
||||
* owner + stats, a transient flash on territory changes, and a
|
||||
* per-scenario initial camera. It exposes a tiny zoom/pan API so the floating
|
||||
* zoom control can drive it (no MapLibre navigation controls are shown).
|
||||
* border for the player, a hover tooltip (owner + key stats), a **click that
|
||||
* switches the dossier to the region's owning nation** (no popup — see #7), a
|
||||
* transient flash on territory changes, and a per-scenario initial camera.
|
||||
*
|
||||
* Ownership for a hovered/clicked region is resolved from the authoritative
|
||||
* RegionMap by the feature's stable ISO_A3 code in `properties.regionId`
|
||||
* (see `regionOwner` in @shared/protocol) — never from the feature's top-level
|
||||
* `id`, which MapLibre repacks into a numeric index at interaction time (#6).
|
||||
* Unowned regions (no RegionMap entry) simply do not change the dossier.
|
||||
*
|
||||
* Zoom and pan are driven directly by the user (MapLibre wheel/trackpad + drag)
|
||||
* — no floating control and no external API.
|
||||
*/
|
||||
export default function WorldMap({
|
||||
regions,
|
||||
@@ -211,7 +206,6 @@ export default function WorldMap({
|
||||
presetId,
|
||||
className,
|
||||
onFocusNation,
|
||||
onMapReady,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
@@ -222,13 +216,11 @@ export default function WorldMap({
|
||||
const playerRef = useRef(playerNationId);
|
||||
const presetRef = useRef(presetId);
|
||||
const onFocusNationRef = useRef(onFocusNation);
|
||||
const onMapReadyRef = useRef(onMapReady);
|
||||
regionsRef.current = regions;
|
||||
nationsRef.current = nations;
|
||||
playerRef.current = playerNationId;
|
||||
presetRef.current = presetId;
|
||||
onFocusNationRef.current = onFocusNation;
|
||||
onMapReadyRef.current = onMapReady;
|
||||
|
||||
// Diffing state for the transient territory-change flash (owned by this component
|
||||
// so a resync with identical data never re-flashes).
|
||||
@@ -257,19 +249,7 @@ export default function WorldMap({
|
||||
// Purely for testability; has no effect on rendering or behavior.
|
||||
(containerRef.current as unknown as { __map?: maplibregl.Map }).__map = map;
|
||||
|
||||
// Expose a tiny zoom/pan API to the floating control (null on unmount).
|
||||
const api: MapApi = {
|
||||
zoomIn: () => map.zoomIn(),
|
||||
zoomOut: () => map.zoomOut(),
|
||||
reset: () => {
|
||||
const c = cameraForPreset(presetRef.current);
|
||||
map.easeTo({ center: c.center, zoom: c.zoom, duration: 600 });
|
||||
},
|
||||
};
|
||||
onMapReadyRef.current?.(api);
|
||||
|
||||
const hoverPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10 });
|
||||
const clickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, offset: 12 });
|
||||
let lastHoverId: string | null = null;
|
||||
|
||||
const loadGeo = (): Promise<FeatureCollection> =>
|
||||
@@ -326,24 +306,33 @@ export default function WorldMap({
|
||||
}
|
||||
flashApiRef.current = animateFlash;
|
||||
|
||||
// The region a hovered/clicked feature belongs to is identified by its stable
|
||||
// ISO_A3 code, read from `properties.regionId` (stamped by `applyRegions`).
|
||||
// We must NOT use the feature's top-level `id`: MapLibre repacks a GeoJSON
|
||||
// source's `id` into a numeric index when it stores the source, so the value
|
||||
// exposed on `e.features[i].id` at interaction time is a number, never the
|
||||
// ISO_A3 — resolving the owner from it is the "no owner" bug (#6).
|
||||
const onHover = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
const id = f.id == null ? null : String(f.id);
|
||||
if (id && id !== lastHoverId) {
|
||||
lastHoverId = id;
|
||||
const p = f.properties as { name?: string; regionId?: string };
|
||||
const code = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||
if (code && code !== lastHoverId) {
|
||||
lastHoverId = code;
|
||||
const geo = baseGeoRef.current;
|
||||
const feat = geo?.features.find((x) => x.id != null && String(x.id) === id);
|
||||
// The base GeoJSON feature's top-level `id` IS the ISO_A3, so match on
|
||||
// that to recover the feature for the hover-highlight outline (the
|
||||
// feature MapLibre hands us has a repacked numeric `id` we can't use).
|
||||
const feat = geo?.features.find((x) => x.id != null && String(x.id) === code);
|
||||
(map.getSource(HOVER_SOURCE) as GeoSource | undefined)?.setData(
|
||||
feat ? { type: 'FeatureCollection', features: [JSON.parse(JSON.stringify(feat))] } : emptyFC(),
|
||||
);
|
||||
}
|
||||
const p = f.properties as { name?: string };
|
||||
const region = id ? regionsRef.current[id] : undefined;
|
||||
const region = regionOwner(regionsRef.current, code);
|
||||
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
||||
hoverPopup
|
||||
.setLngLat(e.lngLat)
|
||||
.setHTML(hoverHtml(p?.name ?? id ?? '', region, nation))
|
||||
.setHTML(hoverHtml(p?.name ?? code, region, nation))
|
||||
.addTo(map);
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
};
|
||||
@@ -355,14 +344,17 @@ export default function WorldMap({
|
||||
map.getCanvas().style.cursor = '';
|
||||
};
|
||||
|
||||
// #7: a click does NOT open a popup — it switches the dossier to the region's
|
||||
// owning nation. The owner is resolved from the stable ISO_A3 in
|
||||
// `properties.regionId` via the RegionMap (never the repacked top-level
|
||||
// `f.id`, #6). A region with no owner in the RegionMap (unowned territory)
|
||||
// leaves the current dossier target unchanged — never a broken dossier.
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
const id = f.id == null ? null : String(f.id);
|
||||
const p = f.properties as { name?: string };
|
||||
const region = id ? regionsRef.current[id] : undefined;
|
||||
const nation = region ? nationsRef.current[region.ownerId] : undefined;
|
||||
clickPopup.setLngLat(e.lngLat).setHTML(clickHtml(p?.name ?? id ?? '', region, nation)).addTo(map);
|
||||
const p = f.properties as { regionId?: string };
|
||||
const code = typeof p?.regionId === 'string' ? p.regionId : '';
|
||||
const region = regionOwner(regionsRef.current, code);
|
||||
if (region?.ownerId) onFocusNationRef.current?.(region.ownerId);
|
||||
};
|
||||
|
||||
@@ -419,7 +411,7 @@ export default function WorldMap({
|
||||
id: FILL_LAYER,
|
||||
type: 'fill',
|
||||
source: COUNTRIES_SOURCE,
|
||||
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.85 },
|
||||
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 1.0 },
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(LINE_LAYER)) {
|
||||
@@ -427,7 +419,7 @@ export default function WorldMap({
|
||||
id: LINE_LAYER,
|
||||
type: 'line',
|
||||
source: COUNTRIES_SOURCE,
|
||||
paint: { 'line-color': ['get', 'stroke'], 'line-width': 0.7, 'line-opacity': 0.95 },
|
||||
paint: { 'line-color': ['get', 'stroke'], 'line-width': 1.25, 'line-opacity': 0.95 },
|
||||
});
|
||||
}
|
||||
// NOTE: layers are added in bottom→top order (each appends on top). The
|
||||
@@ -482,7 +474,6 @@ export default function WorldMap({
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(flashRaf);
|
||||
flashApiRef.current = null;
|
||||
onMapReadyRef.current?.(null);
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
if (containerRef.current) {
|
||||
|
||||
+32
-4
@@ -2,7 +2,16 @@
|
||||
// proxied by Vite in dev and served by Express in prod), so no origin is ever
|
||||
// hardcoded — this works unchanged behind the Traefik proxy.
|
||||
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
||||
import type { AdvisorResponse, CreateGameResponse, GameSummary, TurnResponse } from '@shared/protocol';
|
||||
import type {
|
||||
AddActionResponse,
|
||||
AddDiplomacyResponse,
|
||||
AdvisorResponse,
|
||||
CreateGameResponse,
|
||||
DeleteGameResponse,
|
||||
GameSummary,
|
||||
RemoveQueueItemResponse,
|
||||
TurnResponse,
|
||||
} from '@shared/protocol';
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
@@ -25,14 +34,33 @@ export const api = {
|
||||
body: JSON.stringify({ presetId, playerNationId }),
|
||||
}),
|
||||
getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`),
|
||||
// Delete a saved game entirely. Fast, non-LLM. Returns { deleted: true }.
|
||||
deleteGame: (id: string) =>
|
||||
req<DeleteGameResponse>(`/api/games/${id}`, { method: 'DELETE' }),
|
||||
// Every saved game as a lightweight summary, newest-updated first (for the
|
||||
// home screen's "Saved games" section / resume affordances).
|
||||
listGames: () => req<{ games: GameSummary[] }>('/api/games'),
|
||||
turn: (id: string, action?: string, chat?: { to: string; message: string }) =>
|
||||
req<TurnResponse>(`/api/games/${id}/turn`, {
|
||||
// Queue a free-text player action onto the pending round. No LLM call — the
|
||||
// round is only committed by `jump`. Returns the updated game (with pending).
|
||||
addAction: (id: string, text: string) =>
|
||||
req<AddActionResponse>(`/api/games/${id}/action`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, chat }),
|
||||
body: JSON.stringify({ text }),
|
||||
}),
|
||||
// Queue a diplomatic message to a target nation onto the pending round.
|
||||
addDiplomacy: (id: string, to: string, message: string) =>
|
||||
req<AddDiplomacyResponse>(`/api/games/${id}/diplomacy`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ to, message }),
|
||||
}),
|
||||
// Remove one queued item from the pending round, by its index in `game.pending`.
|
||||
// Fast, non-LLM — no turn advance. Returns the updated game.
|
||||
removeQueueItem: (id: string, index: number) =>
|
||||
req<RemoveQueueItemResponse>(`/api/games/${id}/queue/remove`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ index }),
|
||||
}),
|
||||
// Commit the whole pending round (or an empty one) in a single LLM turn.
|
||||
jump: (id: string) =>
|
||||
req<TurnResponse>(`/api/games/${id}/jump`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -5,6 +5,12 @@ import type { TurnResult } from '@shared/protocol';
|
||||
interface SseHandlers {
|
||||
onState?: (state: GameState) => void;
|
||||
onTurn?: (state: GameState, result: TurnResult) => void;
|
||||
/**
|
||||
* Fired for a server-sent NAMED `event: error` (a MessageEvent with a JSON
|
||||
* body `{ message }`) — e.g. the game was deleted or no longer exists. Pure
|
||||
* connection errors (no data) are ignored and EventSource auto-reconnects.
|
||||
*/
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,8 +43,19 @@ export function useSse(id: string | null, handlers: SseHandlers): void {
|
||||
/* ignore malformed */
|
||||
}
|
||||
};
|
||||
const onError = (e: Event) => {
|
||||
if (e instanceof MessageEvent && e.data) {
|
||||
try {
|
||||
const d = JSON.parse(e.data) as { message?: string };
|
||||
if (d.message) handlersRef.current.onError?.(d.message);
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
}
|
||||
};
|
||||
es.addEventListener('state', onState);
|
||||
es.addEventListener('turn', onTurn);
|
||||
es.addEventListener('error', onError);
|
||||
return () => es.close();
|
||||
}, [id]);
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,5 +20,5 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "server", "shared", "e2e", "vite.config.ts"]
|
||||
"include": ["src", "server", "shared", "e2e", "scripts", "vite.config.ts"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['server/**/*.test.ts'],
|
||||
include: ['server/**/*.test.ts', 'scripts/**/*.test.ts'],
|
||||
testTimeout: 20000,
|
||||
hookTimeout: 20000,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user