v0.2.0 — LLM reliability + batched-round turn model #9
@@ -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_:
|
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,
|
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,
|
then queue actions and diplomatic messages into a pending round; a single "Jump forward" sends
|
||||||
which returns a structured `TurnResult` (events + stat/relation/territory deltas) that the engine
|
the whole batch to the model, which returns a structured `TurnResult` (events + stat/relation/territory
|
||||||
validates, clamps, and applies to the world state. A MapLibre choropleth renders territorial
|
deltas) that the engine validates, clamps, and applies to the world state. A MapLibre choropleth
|
||||||
ownership; the whole thing persists to SQLite and streams over SSE on a single port.
|
renders territorial ownership; the whole thing persists to SQLite and streams over SSE on a single port.
|
||||||
|
|
||||||
## Change process (mandatory)
|
## 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).
|
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`).
|
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. **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).
|
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. **Version bump** — the version is updated in `package.json`.
|
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. **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.**
|
5. **Version bump** — the version is updated in `package.json`.
|
||||||
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.
|
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
|
## Stack
|
||||||
|
|
||||||
@@ -49,11 +50,11 @@ history/
|
|||||||
├── public/
|
├── public/
|
||||||
│ └── countries.geo.json # 177 features, id=ISO_A3 (committed; Vite copies to dist/)
|
│ └── countries.geo.json # 177 features, id=ISO_A3 (committed; Vite copies to dist/)
|
||||||
├── shared/ # NO runtime deps — the single source of truth
|
├── shared/ # NO runtime deps — the single source of truth
|
||||||
│ ├── types.ts # domain types (Nation, GameState, Preset, …)
|
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
||||||
│ └── protocol.ts # LLM TurnResult schema + client↔server messages + pure helpers
|
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/Jump request types + pure helpers
|
||||||
├── server/
|
├── server/
|
||||||
│ ├── index.ts # Express: /api/*, /sse/:id, static+SPA; reads PORT/LLM_*/DATA_DIR
|
│ ├── 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 / advisor
|
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / advisor
|
||||||
│ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config
|
│ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config
|
||||||
│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER
|
│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER
|
||||||
│ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color)
|
│ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color)
|
||||||
@@ -61,7 +62,7 @@ history/
|
|||||||
│ ├── db.ts # SQLite open/save/load
|
│ ├── db.ts # SQLite open/save/load
|
||||||
│ └── *.test.ts # unit tests (vitest)
|
│ └── *.test.ts # unit tests (vitest)
|
||||||
└── src/
|
└── src/
|
||||||
├── App.tsx # screen router: home → pick → game; owns game/regions/advisors state
|
├── App.tsx # screen router: home → pick → game; owns game/regions/advisors/busy state
|
||||||
├── main.tsx # React root + ThemeProvider(dark)
|
├── main.tsx # React root + ThemeProvider(dark)
|
||||||
├── index.css # Tailwind v4 + dark "command-center" theme
|
├── index.css # Tailwind v4 + dark "command-center" theme
|
||||||
├── lib/
|
├── lib/
|
||||||
@@ -73,7 +74,7 @@ history/
|
|||||||
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
||||||
├── WorldMap.tsx # MapLibre basemap + choropleth + click popup + player highlight
|
├── WorldMap.tsx # MapLibre basemap + choropleth + click popup + player highlight
|
||||||
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
├── 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)
|
├── EventFeed.tsx # scrolling event log (newest first)
|
||||||
├── StatBar.tsx
|
├── StatBar.tsx
|
||||||
└── ui/ # 14 shadcn/ui primitives
|
└── ui/ # 14 shadcn/ui primitives
|
||||||
@@ -97,12 +98,18 @@ history/
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **The server is authoritative.** All world state lives in SQLite (`server/db.ts`). The client
|
- **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.
|
renders state and sends intents (`/api/games/:id/action|diplomacy|jump|advisor`); it never computes
|
||||||
`shared/protocol.ts` is the single source of truth for the LLM `TurnResult` and the
|
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.
|
client↔server message shapes — change it there first, then update both sides.
|
||||||
- **No hardcoded origins.** The client uses same-origin relative paths (`/api/...`, `/sse/...`).
|
- **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
|
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`.
|
unchanged behind the proxy — never bake in `http://host:port`.
|
||||||
|
- **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
|
- **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
|
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
|
once with a nudge if the result is empty/invalid, and if it's still unusable emits a generic
|
||||||
@@ -110,11 +117,13 @@ history/
|
|||||||
(model down) surfaces as an HTTP 502 to the client (shown as a toast).
|
(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
|
- **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.
|
server's `RegionMap` and any client coloring so the choropleth always agrees.
|
||||||
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically: the player
|
- **Deterministic stub.** `server/stub.ts` answers the model contract deterministically per
|
||||||
nation's `military` is set to `(current + 5)` (clamped) each turn, one event is emitted
|
queued round item: each `action` item → one military/success event, each `diplomacy` item → one
|
||||||
(action→military, chat→diplomatic, jump→political "The world moves on"), it allies the player with
|
diplomatic/partial event, an empty round → one political "The world moves on" event. The player
|
||||||
the first other nation, and the date advances 2 days. Unit + e2e assertions rely on these exact
|
nation's `military` is set to `(current + 5)` (clamped) once per committed turn (not per item),
|
||||||
values, so keep the stub deterministic.
|
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
|
- 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`.
|
primary, cyan accent). No CSS beyond the Tailwind import in `src/index.css`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes to **History** are listed here, newest first.
|
All notable changes to **History** are listed here, newest first.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- Added a Gitea Actions CI pipeline (`.gitea/workflows/ci.yml`: `typecheck → lint → unit → build → deterministic Playwright e2e`). E2e runs against the app's own dev webServer (`LLM_STUB=1`) — no dind/docker.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# History
|
# History
|
||||||
|
|
||||||
A self-contained, AI-driven **alternate-history simulator** in the spirit of _Pax Historia_ — the
|
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
|
LLM is the world engine. Pick one of five seed scenarios, choose a nation to lead, then queue
|
||||||
actions, send diplomatic messages, or consult an advisor. Every move is sent to the model, which
|
actions and diplomatic messages into a pending round; a single "Jump forward" sends the whole batch
|
||||||
returns a structured `TurnResult` (events + stat / relation / territory deltas) that the engine
|
to the model, which returns a structured `TurnResult` (events + stat / relation / territory deltas)
|
||||||
validates, clamps, and applies to the world state. A MapLibre choropleth renders who owns what;
|
that the engine validates, clamps, and applies to the world state. A MapLibre choropleth renders
|
||||||
everything persists to SQLite and streams over SSE on a single port.
|
who owns what; everything persists to SQLite and streams over SSE on a single port.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────┐ /api + /sse ┌──────────────────────────────┐ OpenAI chat ┌──────────────┐
|
┌────────────┐ /api + /sse ┌──────────────────────────────┐ OpenAI chat ┌──────────────┐
|
||||||
@@ -91,7 +91,9 @@ before auto-merging. After merge, tag the version and deploy manually (`docker c
|
|||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
- **Server is authoritative.** All world state lives in SQLite; the client only renders state and
|
- **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
|
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
|
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
|
advances. Bad model output never crashes; only a network failure (model down) surfaces as a
|
||||||
|
|||||||
+70
-35
@@ -2,19 +2,20 @@ import { test, expect, type Page } from '@playwright/test';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fully deterministic end-to-end test. The Playwright webServer boots the real
|
* 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
|
* stack (Vite → Express) with LLM_STUB=1, so the backend uses the deterministic
|
||||||
* deterministic turn simulator in server/stub.ts: the player nation's `military`
|
* turn simulator in server/stub.ts.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Beyond the core simulation loop it also asserts the polished command-center
|
* Batched-round model: actions and diplomatic messages are queued in
|
||||||
* UI: the dossier's animated stat bars, the map legend (generated from the
|
* `game.pending` (no turn advance, no LLM call); a single "Jump forward"
|
||||||
* region map), and the new event cards (type icon + label + colored outcome
|
* commits the whole batch in one LLM turn. The stub emits one event per
|
||||||
* badge). The map itself is a canvas, so we assert on its DOM legend, not pixels.
|
* 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.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('/');
|
await page.goto('/');
|
||||||
|
|
||||||
// Home: the scenario grid loads with all 5 presets.
|
// 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-stability')).toBeVisible();
|
||||||
await expect(page.getByTestId('stat-population')).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 military = page.getByTestId('stat-military');
|
||||||
|
const date = page.getByTestId('game-date');
|
||||||
await expect(military).toHaveText('80');
|
await expect(military).toHaveText('80');
|
||||||
|
await expect(date).toHaveText('1939-09-01');
|
||||||
await expect(page.getByTestId('event-item')).toHaveCount(0);
|
await expect(page.getByTestId('event-item')).toHaveCount(0);
|
||||||
|
|
||||||
// The map legend is auto-generated from the region map: all 7 nations of the
|
// 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')).toHaveCount(7, { timeout: 15000 });
|
||||||
await expect(page.getByTestId('map-legend-item').filter({ hasText: 'United Kingdom' })).toBeVisible();
|
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-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(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
|
||||||
await expect(military).toHaveText('85', { timeout: 20000 });
|
await expect(military).toHaveText('85', { timeout: 20000 });
|
||||||
await expect(page.getByTestId('event-item')).toHaveCount(1);
|
await expect(date).toHaveText('1939-09-03', { timeout: 20000 });
|
||||||
await expect(
|
|
||||||
page.getByTestId('event-feed').getByText('Mobilize the home guard and fortify Dover').first(),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// The new event card carries its type (icon + label) and a colored outcome badge.
|
// Two events were emitted (one per queued item): one military, one diplomatic.
|
||||||
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 });
|
|
||||||
await expect(page.getByTestId('event-item')).toHaveCount(2);
|
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
|
// The pending queue is now empty — the round was committed and cleared.
|
||||||
// action event (military / success) is second — both with their outcome badges.
|
await expect(page.getByTestId('pending-empty')).toBeVisible();
|
||||||
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('political');
|
await expect(page.getByTestId('title-queued')).not.toBeVisible();
|
||||||
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('neutral');
|
|
||||||
|
// 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');
|
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)
|
* 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,
|
* 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
|
* 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', () => {
|
test.describe('History — save / resume across a page refresh', () => {
|
||||||
async function createUkGameAtTurn2(page: Page): Promise<void> {
|
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('turn-count')).toHaveText('1');
|
||||||
await expect(page.getByTestId('stat-military')).toHaveText('80');
|
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-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('turn-count')).toHaveText('2', { timeout: 20000 });
|
||||||
await expect(page.getByTestId('stat-military')).toHaveText('85', { timeout: 20000 });
|
await expect(page.getByTestId('stat-military')).toHaveText('85', { timeout: 20000 });
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "history",
|
"name": "history",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function makeState(id: string): GameState {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
|
pending: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+130
-7
@@ -1,11 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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 { TurnResult } from '../shared/protocol';
|
||||||
import type { LlmClient } from './llm';
|
import type { LlmClient } from './llm';
|
||||||
|
import { makeStubLlmClient } from './stub';
|
||||||
import {
|
import {
|
||||||
applyTurnResult,
|
applyTurnResult,
|
||||||
|
buildUserPrompt,
|
||||||
isMeaningful,
|
isMeaningful,
|
||||||
parseTurnResult,
|
parseTurnResult,
|
||||||
|
queueRoundItem,
|
||||||
runTurn,
|
runTurn,
|
||||||
validateTurnResult,
|
validateTurnResult,
|
||||||
} from './engine';
|
} 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 } },
|
de: { ...preset.nations[2], stats: { ...preset.nations[2].stats }, territory: [...preset.nations[2].territory], relations: { ...preset.nations[2].relations } },
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
|
pending: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,17 +89,18 @@ const validResult = JSON.stringify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('runTurn', () => {
|
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 original = baseState();
|
||||||
const { state, result } = await runTurn({
|
const { state, result } = await runTurn({
|
||||||
llm: fakeLlm([validResult]),
|
llm: fakeLlm([validResult]),
|
||||||
preset,
|
preset,
|
||||||
state: original,
|
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.turn).toBe(2); // the turn counter advanced
|
||||||
expect(state.date).toBe('1939-09-03');
|
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.uk.stats.military).toBe(85);
|
||||||
expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add)
|
expect(state.nations.de.stats.economy).toBe(80); // 70 + 10 (add)
|
||||||
// relations are symmetric
|
// relations are symmetric
|
||||||
@@ -131,7 +136,7 @@ describe('runTurn', () => {
|
|||||||
llm: fakeLlm([clampResult]),
|
llm: fakeLlm([clampResult]),
|
||||||
preset,
|
preset,
|
||||||
state: baseState(),
|
state: baseState(),
|
||||||
action: 'x',
|
items: [],
|
||||||
});
|
});
|
||||||
expect(state.nations.uk.stats.military).toBe(100);
|
expect(state.nations.uk.stats.military).toBe(100);
|
||||||
expect(state.nations.uk.stats.stability).toBe(0);
|
expect(state.nations.uk.stats.stability).toBe(0);
|
||||||
@@ -142,7 +147,7 @@ describe('runTurn', () => {
|
|||||||
llm: fakeLlm(['total garbage', 'still not json']),
|
llm: fakeLlm(['total garbage', 'still not json']),
|
||||||
preset,
|
preset,
|
||||||
state: baseState(),
|
state: baseState(),
|
||||||
action: 'x',
|
items: [],
|
||||||
});
|
});
|
||||||
expect(state.turn).toBe(2);
|
expect(state.turn).toBe(2);
|
||||||
expect(state.events).toHaveLength(1);
|
expect(state.events).toHaveLength(1);
|
||||||
@@ -162,7 +167,7 @@ describe('runTurn', () => {
|
|||||||
llm: fakeLlm([emptyResult, emptyResult]),
|
llm: fakeLlm([emptyResult, emptyResult]),
|
||||||
preset,
|
preset,
|
||||||
state: baseState(),
|
state: baseState(),
|
||||||
action: 'x',
|
items: [],
|
||||||
});
|
});
|
||||||
expect(state.turn).toBe(2);
|
expect(state.turn).toBe(2);
|
||||||
expect(state.events[0].title).toBe('The turn was unclear');
|
expect(state.events[0].title).toBe('The turn was unclear');
|
||||||
@@ -174,13 +179,116 @@ describe('runTurn', () => {
|
|||||||
const { state: next } = await runTurn({
|
const { state: next } = await runTurn({
|
||||||
llm: fakeLlm([validResult]),
|
llm: fakeLlm([validResult]),
|
||||||
preset,
|
preset,
|
||||||
state, // no action / no chat = jump forward
|
state, // no items = an empty round / jump forward
|
||||||
|
items: [],
|
||||||
});
|
});
|
||||||
expect(next.turn).toBe(4);
|
expect(next.turn).toBe(4);
|
||||||
expect(next.events).toHaveLength(1);
|
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', () => {
|
describe('parseTurnResult', () => {
|
||||||
it('parses a fenced JSON block', () => {
|
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```');
|
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);
|
applyTurnResult(state, result);
|
||||||
expect(JSON.stringify(state)).toBe(snapshot);
|
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', () => {
|
describe('isMeaningful', () => {
|
||||||
|
|||||||
+50
-20
@@ -9,6 +9,7 @@ import type {
|
|||||||
GameState,
|
GameState,
|
||||||
Preset,
|
Preset,
|
||||||
RelationStatus,
|
RelationStatus,
|
||||||
|
RoundItem,
|
||||||
StatKey,
|
StatKey,
|
||||||
} from '../shared/types';
|
} from '../shared/types';
|
||||||
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
import { EVENT_TYPES, RELATION_STATUSES } from '../shared/types';
|
||||||
@@ -64,12 +65,28 @@ export function buildSystemPrompt(preset: Preset): string {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The user message: a compact JSON snapshot + the player's action/chat. */
|
/**
|
||||||
export function buildUserPrompt(
|
* The user message: a compact JSON snapshot of the world plus the player's
|
||||||
state: GameState,
|
* queued round (`items`) — the free-text actions and diplomatic messages to be
|
||||||
action?: string,
|
* simulated this period. `playerActions` is an LLM-friendly rendering of each
|
||||||
chat?: { to: string; message: string },
|
* item; the raw `pending` list is also emitted so the deterministic stub (and any
|
||||||
): string {
|
* 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 = {
|
const payload = {
|
||||||
state: {
|
state: {
|
||||||
turn: state.turn,
|
turn: state.turn,
|
||||||
@@ -88,8 +105,9 @@ export function buildUserPrompt(
|
|||||||
outcome: e.outcome,
|
outcome: e.outcome,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
action: action ?? null,
|
playerActions,
|
||||||
chat: chat ?? null,
|
pending: items,
|
||||||
|
roundInstruction,
|
||||||
};
|
};
|
||||||
return JSON.stringify(payload, null, 2);
|
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
|
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.turn = state.turn + 1; // then we advance to the next turn to play
|
||||||
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
|
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) {
|
for (const ev of result.events) {
|
||||||
next.events.push(makeEvent(ev, actingTurn, next.date));
|
next.events.push(makeEvent(ev, actingTurn, next.date));
|
||||||
}
|
}
|
||||||
@@ -302,22 +322,36 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
|
|||||||
return next;
|
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
|
* 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
|
* model ONCE over the whole pending batch, parse/validate, retry once with a
|
||||||
* invalid — emit a generic "unclear" event and advance. Never throws on bad
|
* nudge if the result is empty/invalid, and — if it is still invalid — emit a
|
||||||
* model output; only the underlying transport/network can throw.
|
* 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: {
|
export async function runTurn(opts: {
|
||||||
llm: LlmClient;
|
llm: LlmClient;
|
||||||
preset: Preset;
|
preset: Preset;
|
||||||
state: GameState;
|
state: GameState;
|
||||||
action?: string;
|
items: RoundItem[];
|
||||||
chat?: { to: string; message: string };
|
|
||||||
}): Promise<{ state: GameState; result: TurnResult }> {
|
}): Promise<{ state: GameState; result: TurnResult }> {
|
||||||
const { llm, preset, state, action, chat } = opts;
|
const { llm, preset, state, items } = opts;
|
||||||
const system = buildSystemPrompt(preset);
|
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([
|
let result = parseTurnResult(await llm.chat([
|
||||||
{ role: 'system', content: system },
|
{ role: 'system', content: system },
|
||||||
@@ -329,11 +363,7 @@ export async function runTurn(opts: {
|
|||||||
{ role: 'system', content: system },
|
{ role: 'system', content: system },
|
||||||
{ role: 'user', content: user },
|
{ role: 'user', content: user },
|
||||||
{ role: 'assistant', content: '' },
|
{ role: 'assistant', content: '' },
|
||||||
{
|
{ role: 'user', content: nudge },
|
||||||
role: 'user',
|
|
||||||
content:
|
|
||||||
'Your previous output was invalid. Respond with ONLY the JSON object described above — no prose, no fences.',
|
|
||||||
},
|
|
||||||
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
|
], { temperature: 0.4, maxTokens: DEFAULT_MAX_TOKENS }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-34
@@ -9,11 +9,11 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { deepClone } from '../shared/protocol';
|
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 type { GameState } from '../shared/types';
|
||||||
import { createLlmClient } from './llm';
|
import { createLlmClient } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import { askAdvisor, runTurn } from './engine';
|
import { askAdvisor, queueRoundItem, runTurn } from './engine';
|
||||||
import { buildRegionMap } from './map';
|
import { buildRegionMap } from './map';
|
||||||
import { createPresetStore } from './presets';
|
import { createPresetStore } from './presets';
|
||||||
import { openDb, type Db } from './db';
|
import { openDb, type Db } from './db';
|
||||||
@@ -113,6 +113,7 @@ app.post('/api/games', (req: Request, res) => {
|
|||||||
date: preset.startDate,
|
date: preset.startDate,
|
||||||
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
|
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
|
||||||
events: [],
|
events: [],
|
||||||
|
pending: [],
|
||||||
};
|
};
|
||||||
db.save(state);
|
db.save(state);
|
||||||
res.json({ game: state });
|
res.json({ game: state });
|
||||||
@@ -149,46 +150,56 @@ app.get('/api/games/:id/map', (req, res) => {
|
|||||||
res.json({ regions: buildRegionMap(game) });
|
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);
|
const game = db.load(id);
|
||||||
if (!game) throw new Error('not_found');
|
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||||
const preset = presetStore.get(game.presetId);
|
const text = (req.body as AddActionRequest | undefined)?.text?.trim();
|
||||||
if (!preset) throw new Error('preset_missing');
|
if (!text) return res.status(400).json({ error: 'empty action' });
|
||||||
const { state, result } = await runTurn({
|
const next = queueRoundItem(game, { kind: 'action', text });
|
||||||
llm,
|
db.save(next);
|
||||||
preset,
|
broadcast(id, 'state', { state: next });
|
||||||
state: game,
|
res.json({ game: next });
|
||||||
action: body?.action,
|
|
||||||
chat: body?.chat,
|
|
||||||
});
|
|
||||||
db.save(state);
|
|
||||||
broadcast(id, 'turn', { state, result });
|
|
||||||
return { state, result };
|
|
||||||
}
|
|
||||||
|
|
||||||
app.post('/api/games/:id/turn', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { state, result } = await handleTurn(req.params.id, req.body as TurnRequest);
|
|
||||||
res.json({ state, result });
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
if (msg === 'not_found' || msg === 'preset_missing') {
|
|
||||||
return res.status(404).json({ error: msg });
|
|
||||||
}
|
|
||||||
res.status(502).json({ error: `turn failed: ${msg}` });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
app.post('/api/games/:id/jump', async (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const game = db.load(id);
|
||||||
|
if (!game) return res.status(404).json({ error: 'game not found' });
|
||||||
|
const preset = presetStore.get(game.presetId);
|
||||||
|
if (!preset) return res.status(404).json({ error: 'preset_missing' });
|
||||||
try {
|
try {
|
||||||
// Jump forward = a turn with no player action (auto-simulate the world).
|
const { state, result } = await runTurn({
|
||||||
const { state, result } = await handleTurn(req.params.id, {});
|
llm,
|
||||||
|
preset,
|
||||||
|
state: game,
|
||||||
|
items: game.pending,
|
||||||
|
});
|
||||||
|
db.save(state);
|
||||||
|
broadcast(id, 'turn', { state, result });
|
||||||
res.json({ state, result });
|
res.json({ state, result });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(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}` });
|
res.status(502).json({ error: `jump failed: ${msg}` });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const state: GameState = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
|
pending: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('buildRegionMap', () => {
|
describe('buildRegionMap', () => {
|
||||||
|
|||||||
+35
-30
@@ -2,11 +2,12 @@
|
|||||||
// sets LLM_STUB=1) so the whole loop is reproducible with no live model. It
|
// 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.
|
// speaks the OpenAI chat-completions wire shape, so it plugs in as a Transport.
|
||||||
import { createLlmClient, type ChatMessage, type Transport } from './llm';
|
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>>';
|
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 {
|
export interface StubPayload {
|
||||||
state?: {
|
state?: {
|
||||||
turn: number;
|
turn: number;
|
||||||
@@ -14,8 +15,8 @@ export interface StubPayload {
|
|||||||
playerNationId: string;
|
playerNationId: string;
|
||||||
nations: Record<string, { stats: Record<string, number> }>;
|
nations: Record<string, { stats: Record<string, number> }>;
|
||||||
};
|
};
|
||||||
action?: string | null;
|
/** The raw pending round the engine emitted (one entry per queued item). */
|
||||||
chat?: { to: string; message: string } | null;
|
pending?: RoundItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncate(s: string, n: number): string {
|
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
|
* 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
|
* military by 5 each turn (clamped to 0–100) and emits one event per queued
|
||||||
* predict the exact next state for assertions.
|
* 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 {
|
export function deterministicTurn(p: StubPayload): TurnResult {
|
||||||
const state = p.state;
|
const state = p.state;
|
||||||
@@ -35,36 +40,36 @@ export function deterministicTurn(p: StubPayload): TurnResult {
|
|||||||
const otherId = state
|
const otherId = state
|
||||||
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
|
? (Object.keys(state.nations).find((id) => id !== pid) ?? '')
|
||||||
: 'us';
|
: '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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: p.chat
|
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
nation: pid,
|
nation: pid,
|
||||||
type: 'diplomatic' as const,
|
type: 'political',
|
||||||
title: 'Diplomatic outreach',
|
title: 'The world moves on',
|
||||||
text: 'Your message is received by the target nation.',
|
text: 'Weeks pass without a decisive event.',
|
||||||
outcome: 'partial' as const,
|
outcome: 'neutral',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: items.map((it) =>
|
||||||
{
|
it.kind === 'action'
|
||||||
nation: pid,
|
? {
|
||||||
type: 'political' as const,
|
nation: pid,
|
||||||
title: 'The world moves on',
|
type: 'military' as const,
|
||||||
text: 'Weeks pass; the great powers posture and rearm quietly.',
|
title: `Action: ${truncate(it.text, 60)}`,
|
||||||
outcome: 'neutral' as const,
|
text: 'Your move takes effect across the region.',
|
||||||
},
|
outcome: 'success' as const,
|
||||||
];
|
}
|
||||||
|
: {
|
||||||
|
nation: pid,
|
||||||
|
type: 'diplomatic' as const,
|
||||||
|
title: 'Diplomatic outreach',
|
||||||
|
text: 'Your message is received.',
|
||||||
|
outcome: 'partial' as const,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: addDays(state?.date ?? '1939-09-01', 2),
|
date: addDays(state?.date ?? '1939-09-01', 2),
|
||||||
|
|||||||
+21
-5
@@ -72,12 +72,28 @@ export interface CreateGameResponse {
|
|||||||
game: GameState;
|
game: GameState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnRequest {
|
/** Queue a free-text player action onto the pending round (no turn advance). */
|
||||||
/** Free-text player action. Omit for a "jump forward" auto turn. */
|
export interface AddActionRequest {
|
||||||
action?: string;
|
text: string;
|
||||||
/** Optional diplomatic message to a target nation. */
|
|
||||||
chat?: { to: string; message: 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 {
|
export interface TurnResponse {
|
||||||
state: GameState;
|
state: GameState;
|
||||||
result: TurnResult;
|
result: TurnResult;
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ export interface EventEntry {
|
|||||||
outcome: EventOutcome;
|
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. */
|
/** The full, persisted state of one game. Saved to SQLite by id. */
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -84,6 +95,12 @@ export interface GameState {
|
|||||||
date: string;
|
date: string;
|
||||||
nations: Record<string, Nation>;
|
nations: Record<string, Nation>;
|
||||||
events: EventEntry[];
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+26
-32
@@ -225,38 +225,37 @@ export default function App() {
|
|||||||
[screen],
|
[screen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const runAction = useCallback(
|
// Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
|
||||||
async (action: string) => {
|
// list updates from the response and the SSE `state` event.
|
||||||
|
const runAddAction = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
setBusy(true);
|
|
||||||
try {
|
try {
|
||||||
const { state } = await api.turn(game.id, action);
|
const { game: g } = await api.addAction(game.id, text);
|
||||||
setGame(state);
|
setGame(g);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[game],
|
[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) => {
|
async (to: string, message: string) => {
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
setBusy(true);
|
|
||||||
try {
|
try {
|
||||||
const { state } = await api.turn(game.id, undefined, { to, message });
|
const { game: g } = await api.addDiplomacy(game.id, to, message);
|
||||||
setGame(state);
|
setGame(g);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toastError(e);
|
toastError(e);
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[game],
|
[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 () => {
|
const runJump = useCallback(async () => {
|
||||||
if (!game) return;
|
if (!game) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -321,8 +320,8 @@ export default function App() {
|
|||||||
regions={regions}
|
regions={regions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAction={runAction}
|
onAddAction={runAddAction}
|
||||||
onChat={runChat}
|
onAddDiplomacy={runAddDiplomacy}
|
||||||
onAdvisor={askAdvisor}
|
onAdvisor={askAdvisor}
|
||||||
onJump={runJump}
|
onJump={runJump}
|
||||||
onHome={goHome}
|
onHome={goHome}
|
||||||
@@ -360,8 +359,8 @@ interface GameScreenProps {
|
|||||||
regions: RegionMap;
|
regions: RegionMap;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
onAction: (a: string) => void;
|
onAddAction: (a: string) => void;
|
||||||
onChat: (to: string, m: string) => void;
|
onAddDiplomacy: (to: string, m: string) => void;
|
||||||
onAdvisor: (p?: string) => void;
|
onAdvisor: (p?: string) => void;
|
||||||
onJump: () => void;
|
onJump: () => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
@@ -383,8 +382,8 @@ function GameScreen({
|
|||||||
regions,
|
regions,
|
||||||
busy,
|
busy,
|
||||||
advisors,
|
advisors,
|
||||||
onAction,
|
onAddAction,
|
||||||
onChat,
|
onAddDiplomacy,
|
||||||
onAdvisor,
|
onAdvisor,
|
||||||
onJump,
|
onJump,
|
||||||
onHome,
|
onHome,
|
||||||
@@ -561,7 +560,7 @@ function GameScreen({
|
|||||||
<EventFeed events={game.events} nations={game.nations} inWindow />
|
<EventFeed events={game.events} nations={game.nations} inWindow />
|
||||||
</FloatingWindow>
|
</FloatingWindow>
|
||||||
|
|
||||||
{/* Command deck ("Your move"), bottom-center — the prominent typing spot. */}
|
{/* Command deck ("Your move"), bottom-center — queue items, then commit. */}
|
||||||
<FloatingWindow
|
<FloatingWindow
|
||||||
id="action"
|
id="action"
|
||||||
anchor="bottom-center"
|
anchor="bottom-center"
|
||||||
@@ -569,16 +568,11 @@ function GameScreen({
|
|||||||
title="Your move"
|
title="Your move"
|
||||||
titleIcon={<Radar className="size-4 text-primary" />}
|
titleIcon={<Radar className="size-4 text-primary" />}
|
||||||
titleExtra={
|
titleExtra={
|
||||||
<Button
|
game.pending.length > 0 ? (
|
||||||
variant="secondary"
|
<Badge variant="secondary" className="shrink-0 text-xs tabular-nums" data-testid="title-queued">
|
||||||
size="sm"
|
{game.pending.length} queued
|
||||||
onClick={onJump}
|
</Badge>
|
||||||
disabled={busy}
|
) : undefined
|
||||||
data-testid="jump-forward"
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
{busy ? 'Simulating…' : 'Jump forward'}
|
|
||||||
</Button>
|
|
||||||
}
|
}
|
||||||
collapsed={!!collapsed.action}
|
collapsed={!!collapsed.action}
|
||||||
onCollapsedChange={(c) => setCollapsedFor('action', c)}
|
onCollapsedChange={(c) => setCollapsedFor('action', c)}
|
||||||
@@ -588,8 +582,8 @@ function GameScreen({
|
|||||||
game={game}
|
game={game}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAction={onAction}
|
onAddAction={onAddAction}
|
||||||
onChat={onChat}
|
onAddDiplomacy={onAddDiplomacy}
|
||||||
onAdvisor={onAdvisor}
|
onAdvisor={onAdvisor}
|
||||||
onJump={onJump}
|
onJump={onJump}
|
||||||
inWindow
|
inWindow
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
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 { Button } from './ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
@@ -10,36 +11,38 @@ import type { GameState } from '@shared/types';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: GameState;
|
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;
|
busy: boolean;
|
||||||
/** Advisor brainstorm suggestions from the last request. */
|
/** Advisor brainstorm suggestions from the last request. */
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
onAction: (action: string) => void;
|
/** Queue a free-text action onto the pending round (no turn advance). */
|
||||||
onChat: (to: string, message: string) => void;
|
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;
|
onAdvisor: (prompt?: string) => void;
|
||||||
|
/** Commit the whole pending round in a single LLM turn. */
|
||||||
onJump: () => void;
|
onJump: () => void;
|
||||||
/**
|
/**
|
||||||
* Render without the outer card chrome (the surrounding floating window
|
* Render without the outer card chrome (the surrounding floating window
|
||||||
* provides the border, the "Your move" title, and the "Jump forward" button
|
* provides the border and the "Your move" title). Same content, same testids.
|
||||||
* in its title bar). Same content, same testids.
|
|
||||||
*/
|
*/
|
||||||
inWindow?: boolean;
|
inWindow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player's command deck ("Your move"). Three tabs — Action (free-text move),
|
* The player's command deck ("Your move"). Three tabs — Action, Diplomacy,
|
||||||
* Diplomacy (a message to a specific nation), Advisor (ask the LLM for ideas,
|
* Advisor — each with a small "Add" button that queues an item onto the pending
|
||||||
* "Use" feeds one into the Action tab) — share one prominent Send button that
|
* round (these never advance the turn). Below the tabs a visible pending-queue
|
||||||
* routes to the active tab. While a turn resolves it shows a spinner + a hint
|
* list and one prominent "Jump forward" button commit the whole round in a
|
||||||
* that turns take a few seconds. A "Jump forward" button auto-simulates the
|
* single LLM turn. While a jump is in flight the commit is disabled and a hint
|
||||||
* world with no player move.
|
* is shown; an empty jump (nothing queued) is allowed and advances time.
|
||||||
*/
|
*/
|
||||||
export default function ActionPanel({
|
export default function ActionPanel({
|
||||||
game,
|
game,
|
||||||
busy,
|
busy,
|
||||||
advisors,
|
advisors,
|
||||||
onAction,
|
onAddAction,
|
||||||
onChat,
|
onAddDiplomacy,
|
||||||
onAdvisor,
|
onAdvisor,
|
||||||
onJump,
|
onJump,
|
||||||
inWindow = false,
|
inWindow = false,
|
||||||
@@ -54,18 +57,20 @@ export default function ActionPanel({
|
|||||||
.filter(([id]) => id !== game.playerNationId)
|
.filter(([id]) => id !== game.playerNationId)
|
||||||
.sort(([a], [b]) => a.localeCompare(b));
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
const submitAction = () => {
|
const nationName = (id?: string) => (id ? (game.nations[id]?.name ?? id) : '');
|
||||||
|
|
||||||
|
const queueAction = () => {
|
||||||
const text = action.trim();
|
const text = action.trim();
|
||||||
if (!text || busy) return;
|
if (!text || busy) return;
|
||||||
|
onAddAction(text);
|
||||||
setAction('');
|
setAction('');
|
||||||
onAction(text);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitChat = () => {
|
const queueDiplomacy = () => {
|
||||||
const msg = message.trim();
|
const msg = message.trim();
|
||||||
if (!msg || !target || busy) return;
|
if (!msg || !target || busy) return;
|
||||||
|
onAddDiplomacy(target, msg);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
onChat(target, msg);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyIdea = (idea: string) => {
|
const applyIdea = (idea: string) => {
|
||||||
@@ -73,41 +78,17 @@ export default function ActionPanel({
|
|||||||
setTab('action');
|
setTab('action');
|
||||||
};
|
};
|
||||||
|
|
||||||
// The single Send button routes to the active tab.
|
const pending = game.pending;
|
||||||
const sendDisabled =
|
const queuedLabel = busy
|
||||||
busy ||
|
|
||||||
(tab === 'action'
|
|
||||||
? !action.trim()
|
|
||||||
: tab === 'diplo'
|
|
||||||
? !message.trim() || !target
|
|
||||||
: false);
|
|
||||||
|
|
||||||
const sendLabel = busy
|
|
||||||
? 'Simulating…'
|
? 'Simulating…'
|
||||||
: tab === 'action'
|
: `Jump forward${pending.length > 0 ? ` · ${pending.length} queued` : ''}`;
|
||||||
? 'Take action'
|
|
||||||
: tab === 'diplo'
|
|
||||||
? 'Send message'
|
|
||||||
: 'Ask the advisor';
|
|
||||||
|
|
||||||
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 : (
|
const header = inWindow ? null : (
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Radar className="size-4 text-primary" />
|
<Radar className="size-4 text-primary" />
|
||||||
<CardTitle className="text-base">Your move</CardTitle>
|
<CardTitle className="text-base">Your move</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
|
||||||
{busy ? 'Simulating…' : 'Jump forward'}
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -134,6 +115,15 @@ export default function ActionPanel({
|
|||||||
rows={3}
|
rows={3}
|
||||||
data-testid="action-textarea"
|
data-testid="action-textarea"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={queueAction}
|
||||||
|
disabled={busy || !action.trim()}
|
||||||
|
data-testid="add-action"
|
||||||
|
>
|
||||||
|
Add action
|
||||||
|
</Button>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="diplo" className="space-y-2">
|
<TabsContent value="diplo" className="space-y-2">
|
||||||
@@ -156,6 +146,15 @@ export default function ActionPanel({
|
|||||||
rows={3}
|
rows={3}
|
||||||
data-testid="chat-message"
|
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>
|
||||||
|
|
||||||
<TabsContent value="advisor" className="space-y-2">
|
<TabsContent value="advisor" className="space-y-2">
|
||||||
@@ -165,6 +164,14 @@ export default function ActionPanel({
|
|||||||
placeholder="Optional focus — e.g. “the nuclear standoff”"
|
placeholder="Optional focus — e.g. “the nuclear standoff”"
|
||||||
data-testid="advisor-prompt"
|
data-testid="advisor-prompt"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAdvisor(prompt.trim() || undefined)}
|
||||||
|
data-testid="ask-advisor"
|
||||||
|
>
|
||||||
|
Ask advisor
|
||||||
|
</Button>
|
||||||
{advisors.length > 0 && (
|
{advisors.length > 0 && (
|
||||||
<ul className="space-y-1.5" data-testid="advisor-list">
|
<ul className="space-y-1.5" data-testid="advisor-list">
|
||||||
{advisors.map((a, i) => (
|
{advisors.map((a, i) => (
|
||||||
@@ -184,14 +191,52 @@ export default function ActionPanel({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Pending queue + the prominent commit — always visible, in any mode. */}
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<Button className="h-11 w-full" onClick={send} disabled={sendDisabled} data-testid="action-submit">
|
<div className="rounded-lg border border-border/60 bg-card/40 p-2.5" data-testid="pending-list">
|
||||||
{busy ? (
|
{pending.length === 0 ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<p className="text-xs text-muted-foreground" data-testid="pending-empty">
|
||||||
|
Nothing queued — add an action or a message, then jump forward.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Send className="size-4" />
|
<>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Queued this round
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-[11px] tabular-nums">
|
||||||
|
{pending.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{pending.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={`${i}-${item.text}`}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
data-testid="pending-item"
|
||||||
|
data-kind={item.kind}
|
||||||
|
>
|
||||||
|
{item.kind === 'action' ? (
|
||||||
|
<Zap className="size-3.5 shrink-0 text-amber-400" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<MessageSquare className="size-3.5 shrink-0 text-cyan-400" aria-hidden />
|
||||||
|
)}
|
||||||
|
{item.kind === 'diplomacy' && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
→ {nationName(item.to)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-foreground/85">{item.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{sendLabel}
|
</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>
|
</Button>
|
||||||
{busy && (
|
{busy && (
|
||||||
<p
|
<p
|
||||||
|
|||||||
+20
-4
@@ -2,7 +2,14 @@
|
|||||||
// proxied by Vite in dev and served by Express in prod), so no origin is ever
|
// proxied by Vite in dev and served by Express in prod), so no origin is ever
|
||||||
// hardcoded — this works unchanged behind the Traefik proxy.
|
// hardcoded — this works unchanged behind the Traefik proxy.
|
||||||
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
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> {
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
@@ -28,11 +35,20 @@ export const api = {
|
|||||||
// Every saved game as a lightweight summary, newest-updated first (for the
|
// Every saved game as a lightweight summary, newest-updated first (for the
|
||||||
// home screen's "Saved games" section / resume affordances).
|
// home screen's "Saved games" section / resume affordances).
|
||||||
listGames: () => req<{ games: GameSummary[] }>('/api/games'),
|
listGames: () => req<{ games: GameSummary[] }>('/api/games'),
|
||||||
turn: (id: string, action?: string, chat?: { to: string; message: string }) =>
|
// Queue a free-text player action onto the pending round. No LLM call — the
|
||||||
req<TurnResponse>(`/api/games/${id}/turn`, {
|
// 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',
|
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) =>
|
jump: (id: string) =>
|
||||||
req<TurnResponse>(`/api/games/${id}/jump`, {
|
req<TurnResponse>(`/api/games/${id}/jump`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user