Compare commits

...
Author SHA1 Message Date
kamilkrzywda f622cb676a url deep-link: game id in #/game/<id>, multi-tab + bad-link fallback (v0.3.0)
CI / build + unit + e2e (pull_request) Successful in 1m52s
2026-10-11 15:32:51 +00:00
kamilkrzywda 502fbd4874 Merge pull request 'v0.2.0 — LLM reliability + batched-round turn model' (#9) from reliability-batched-round into master
CI / build + unit + e2e (push) Successful in 1m52s
2026-10-11 16:46:01 +02:00
kamilkrzywda 9bce14254d batched round: queue actions/diplomacy, jump forward commits one turn (v0.2.0)
CI / build + unit + e2e (pull_request) Successful in 1m51s
2026-10-11 14:36:35 +00:00
kamilkrzywda 3297658c81 reliability: low reasoning_effort, retry-with-backoff, LLM call logging
- send top-level reasoning_effort: "low" (LLM_REASONING_EFFORT) to cut the qwen3.8-27b CoT ~85%, so content is no longer truncated to null -> the ~50% 502s
- new env: LLM_MAX_TOKENS (default 4000, per-call opts.maxTokens still overrides), LLM_REASONING_EFFORT (default "low", off/empty omits the field)
- chat() retries up to 3 attempts: timeout/abort, 5xx, network errors (ECONNRESET/ECONNREFUSED/socket closed), null/empty content; backoff 2s->3s (2000*1.5^(n-1), +/-20% jitter); 4xx throws immediately
- logging: '[llm] chat ok' {ms, finishReason} on success; '[llm] chat failed' {attempts, lastStatus, message, totalMs} on final failure
- tests: 12 in llm.test.ts (retry success/failure, no 4xx retry, null-content retry, logging spies); AGENTS.md env table + gotchas updated
2026-10-11 13:46:18 +00:00
kamilkrzywda 612de2fc40 Merge pull request 'Add Gitea Actions CI + switch to PR-based workflow' (#1) from ci-pipeline into master
CI / build + unit + e2e (push) Successful in 1m59s
2026-10-11 10:28:16 +02:00
18 changed files with 1166 additions and 324 deletions
+55 -27
View File
@@ -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 first** — file an issue on Gitea (`https://git.kkhost.pl`, repo `OpenCode/History`) describing the change (title, scope, acceptance criteria) before or alongside creating the branch. The issue is the single source of intent; the PR references it.
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/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|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / 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/
@@ -73,7 +74,7 @@ history/
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
├── WorldMap.tsx # MapLibre basemap + choropleth + click popup + player highlight
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + "Jump forward"
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list + "Jump forward"
├── EventFeed.tsx # scrolling event log (newest first)
├── StatBar.tsx
└── ui/ # 14 shadcn/ui primitives
@@ -97,12 +98,25 @@ 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.
- **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 +124,13 @@ 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.
- **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.
- 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 +153,31 @@ 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
- **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
+16
View File
@@ -2,6 +2,22 @@
All notable changes to **History** are listed here, newest first.
## 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.
+8 -6
View File
@@ -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
+173 -35
View File
@@ -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,106 @@ test.describe('History — save / resume across a page refresh', () => {
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
});
});
/**
* 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, '#/');
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.1.0",
"version": "0.3.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+1
View File
@@ -21,6 +21,7 @@ function makeState(id: string): GameState {
},
},
events: [],
pending: [],
};
}
+130 -7
View File
@@ -1,11 +1,14 @@
import { describe, expect, it } from 'vitest';
import type { GameState, Preset } from '../shared/types';
import type { GameState, Preset, RoundItem } from '../shared/types';
import type { TurnResult } from '../shared/protocol';
import type { LlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import {
applyTurnResult,
buildUserPrompt,
isMeaningful,
parseTurnResult,
queueRoundItem,
runTurn,
validateTurnResult,
} from './engine';
@@ -68,6 +71,7 @@ function baseState(): GameState {
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
},
events: [],
pending: [],
};
}
@@ -85,17 +89,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 +136,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 +147,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 +167,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 +179,116 @@ 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 }]);
// the original object is not mutated (a fresh queue was consumed)
expect(original.pending).toHaveLength(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);
});
});
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);
});
});
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 +345,21 @@ describe('applyTurnResult', () => {
applyTurnResult(state, result);
expect(JSON.stringify(state)).toBe(snapshot);
});
it('clears the pending round on commit', () => {
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(state.pending).toHaveLength(1); // original untouched
});
});
describe('isMeaningful', () => {
+50 -20
View File
@@ -9,6 +9,7 @@ import type {
GameState,
Preset,
RelationStatus,
RoundItem,
StatKey,
} from '../shared/types';
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
@@ -64,12 +65,28 @@ export function buildSystemPrompt(preset: Preset): string {
].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,
@@ -88,8 +105,9 @@ export function buildUserPrompt(
outcome: e.outcome,
})),
},
action: action ?? null,
chat: chat ?? null,
playerActions,
pending: items,
roundInstruction,
};
return JSON.stringify(payload, null, 2);
}
@@ -269,6 +287,8 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
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 +322,36 @@ 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. Exported so the append-without-advance behavior
* is unit-testable, and reused by the server's /action and /diplomacy routes.
*/
export function queueRoundItem(state: GameState, item: RoundItem): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.pending.push(item);
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 +363,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 }));
}
+41 -30
View File
@@ -9,11 +9,11 @@ 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 } 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, runTurn } from './engine';
import { buildRegionMap } from './map';
import { createPresetStore } from './presets';
import { openDb, type Db } from './db';
@@ -113,6 +113,7 @@ app.post('/api/games', (req: Request, res) => {
date: preset.startDate,
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
events: [],
pending: [],
};
db.save(state);
res.json({ game: state });
@@ -149,46 +150,56 @@ 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 }> {
// 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) throw new Error('not_found');
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 });
});
// 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) throw new Error('preset_missing');
if (!preset) return res.status(404).json({ error: 'preset_missing' });
try {
const { state, result } = await runTurn({
llm,
preset,
state: game,
action: body?.action,
chat: body?.chat,
items: game.pending,
});
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}` });
}
});
app.post('/api/games/:id/jump', async (req, res) => {
try {
// Jump forward = a turn with no player action (auto-simulate the world).
const { state, result } = await handleTurn(req.params.id, {});
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
View File
@@ -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),
}),
);
});
});
+217 -32
View File
@@ -4,15 +4,25 @@
// 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;
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`, {
/**
* 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: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
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) };
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`chat failed: HTTP ${res.status} ${text.slice(0, 200)}`);
}
// 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 } }[];
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,
};
const content = body.choices?.[0]?.message?.content;
if (typeof content !== 'string') {
throw new Error('chat failed: no choices[0].message.content in response');
}
return content;
const body = (await res.json()) as {
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' || content === '') {
return {
status: res.status,
error: new Error('chat failed: no choices[0].message.content in response'),
retryable: true,
};
}
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[]> {
+1
View File
@@ -29,6 +29,7 @@ const state: GameState = {
},
},
events: [],
pending: [],
};
describe('buildRegionMap', () => {
+30 -25
View File
@@ -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
const events: TurnEventSpec[] =
items.length === 0
? [
{
nation: pid,
type: 'military' as const,
title: `Action: ${truncate(p.action, 60)}`,
text: 'Your move takes effect across the region.',
outcome: 'success' as const,
type: 'political',
title: 'The world moves on',
text: 'Weeks pass without a decisive event.',
outcome: 'neutral',
},
]
: p.chat
? [
{
: 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 by the target nation.',
text: 'Your message is received.',
outcome: 'partial' as const,
},
]
: [
{
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,
},
];
);
return {
date: addDays(state?.date ?? '1939-09-01', 2),
+21 -5
View File
@@ -72,12 +72,28 @@ 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;
+17
View File
@@ -74,6 +74,17 @@ 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;
};
/** The full, persisted state of one game. Saved to SQLite by id. */
export interface GameState {
id: string;
@@ -84,6 +95,12 @@ 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[];
}
/**
+118 -45
View File
@@ -111,6 +111,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[]>([]);
@@ -123,6 +145,13 @@ export default function App() {
// 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);
// 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 +171,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);
setScreen({ name: 'game' });
} catch {
if (alive) {
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 +208,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;
@@ -225,38 +294,37 @@ 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.
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);
setGame(g);
} catch (e) {
toastError(e);
} finally {
setBusy(false);
}
},
[game],
);
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);
setGame(g);
} catch (e) {
toastError(e);
} finally {
setBusy(false);
}
},
[game],
);
// 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);
@@ -283,20 +351,24 @@ export default function App() {
[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);
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 +376,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 +386,9 @@ export default function App() {
setAdvisors([]);
}, []);
// Expose the latest game + handlers to the mount-once hashchange listener.
latestRef.current = { game, goHome, loadGame };
if (screen.name === 'game' && game) {
return (
<>
@@ -321,8 +399,8 @@ export default function App() {
regions={regions}
busy={busy}
advisors={advisors}
onAction={runAction}
onChat={runChat}
onAddAction={runAddAction}
onAddDiplomacy={runAddDiplomacy}
onAdvisor={askAdvisor}
onJump={runJump}
onHome={goHome}
@@ -360,8 +438,8 @@ interface GameScreenProps {
regions: RegionMap;
busy: boolean;
advisors: string[];
onAction: (a: string) => void;
onChat: (to: string, m: string) => void;
onAddAction: (a: string) => void;
onAddDiplomacy: (to: string, m: string) => void;
onAdvisor: (p?: string) => void;
onJump: () => void;
onHome: () => void;
@@ -383,8 +461,8 @@ function GameScreen({
regions,
busy,
advisors,
onAction,
onChat,
onAddAction,
onAddDiplomacy,
onAdvisor,
onJump,
onHome,
@@ -561,7 +639,7 @@ 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 — queue items, then commit. */}
<FloatingWindow
id="action"
anchor="bottom-center"
@@ -569,16 +647,11 @@ function GameScreen({
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
}
collapsed={!!collapsed.action}
onCollapsedChange={(c) => setCollapsedFor('action', c)}
@@ -588,8 +661,8 @@ function GameScreen({
game={game}
busy={busy}
advisors={advisors}
onAction={onAction}
onChat={onChat}
onAddAction={onAddAction}
onAddDiplomacy={onAddDiplomacy}
onAdvisor={onAdvisor}
onJump={onJump}
inWindow
+95 -50
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Loader2, Radar, Send } from 'lucide-react';
import { Loader2, MessageSquare, Radar, Zap } from 'lucide-react';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Input } from './ui/input';
@@ -10,36 +11,38 @@ import type { GameState } from '@shared/types';
interface Props {
game: GameState;
/** A turn is currently resolving (action/chat/jump) — disable those controls. */
/** A jump (the only LLM call) is currently in flight — disable the commit. */
busy: 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;
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;
}
/**
* 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 and one prominent "Jump forward" button commit the whole round in a
* single LLM turn. While a jump is in flight the commit is disabled and a hint
* is shown; an empty jump (nothing queued) is allowed and advances time.
*/
export default function ActionPanel({
game,
busy,
advisors,
onAction,
onChat,
onAddAction,
onAddDiplomacy,
onAdvisor,
onJump,
inWindow = false,
@@ -54,18 +57,20 @@ export default function ActionPanel({
.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 +78,17 @@ 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;
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 +115,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 +146,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 +164,14 @@ 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)}
data-testid="ask-advisor"
>
Ask advisor
</Button>
{advisors.length > 0 && (
<ul className="space-y-1.5" data-testid="advisor-list">
{advisors.map((a, i) => (
@@ -184,14 +191,52 @@ 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 />
)}
{sendLabel}
{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>
</li>
))}
</ul>
</>
)}
</div>
<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
+20 -4
View File
@@ -2,7 +2,14 @@
// 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,
GameSummary,
TurnResponse,
} from '@shared/protocol';
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
@@ -28,11 +35,20 @@ export const api = {
// 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 }),
}),
// 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',