Compare commits

..
14 changed files with 658 additions and 46 deletions
+35 -7
View File
@@ -14,7 +14,7 @@ renders territorial ownership; the whole thing persists to SQLite and streams ov
## Change process (mandatory)
1. **Small, incremental, well-tested changes** — work is done one change at a time, in smaller parts; each part is tested before moving on. Connected tasks that touch the same area of code MAY be done together on one branch/PR with a single version bump (e.g. two tweaks to the same panel).
2. **Gitea issue 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.
2. **Gitea issue + `Fixes #N` in the PR** — the PR references (and, via `Fixes #N`, closes) the Gitea issue(s) the work addresses (`https://git.kkhost.pl`, repo `OpenCode/History`). If the user has already filed issue(s) for the change, reference those directly in the PR — no separate changelog issue is created; only file a fresh issue when the work has none pre-filed (e.g. a fix originating purely from chat feedback). `CHANGELOG.md` is always the durable changelog of what shipped.
3. **Feature branch + PR** — every change lands on a feature branch off `master` (no direct pushes to `master`). Open a pull request against `master` on Gitea; reference the issue in the PR description.
4. **Changelog entry** — every change is listed in `CHANGELOG.md` with a version, date and time (format: `## v<version> — YYYY-MM-DD HH:MM`, then bullet points of what changed).
5. **Version bump** — the version is updated in `package.json`.
@@ -51,10 +51,10 @@ history/
│ └── 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, RoundItem, Preset, …)
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/Jump request types + pure helpers
│ └── protocol.ts # LLM TurnResult schema + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers
├── server/
│ ├── 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
│ ├── index.ts # Express: /api/* (action|diplomacy|queue/remove|jump|advisor|games|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
│ ├── engine.ts # pure: buildPrompt / parse+validate / applyTurnResult / runTurn / queueRoundItem / removeRoundItem / advisor
│ ├── llm.ts # LlmClient (injectable transport, retry+backoff, logging) + env config
│ ├── stub.ts # deterministic LLM transport (tests/e2e) + ADVISOR_MARKER
│ ├── map.ts # GameState → RegionMap (ISO_A3 → owner + stable color)
@@ -62,7 +62,7 @@ history/
│ ├── db.ts # SQLite open/save/load
│ └── *.test.ts # unit tests (vitest)
└── src/
├── App.tsx # screen router: home → pick → game; owns game/regions/advisors/busy 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/
@@ -74,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 + pending-queue list + "Jump forward"
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward"
├── EventFeed.tsx # scrolling event log (newest first)
├── StatBar.tsx
└── ui/ # 14 shadcn/ui primitives
@@ -87,7 +87,7 @@ history/
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
| `npm run build` | `vite build` (client → `dist/`) then `tsup server/index.ts` → `server/dist/index.js` |
| `npm start` | run the compiled server (`node server/dist/index.js`) on :4321 |
| `npm test` | vitest unit tests in `server/` (19 tests) |
| `npm test` | vitest unit tests in `server/` (39 tests) |
| `npm run typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
| `npm run lint` | ESLint flat config |
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
@@ -104,12 +104,30 @@ history/
- **No hardcoded origins.** The client uses same-origin relative paths (`/api/...`, `/sse/...`).
In dev, Vite proxies them to :4321; in prod/Traefik they're the same origin. So the app works
unchanged behind the proxy — never bake in `http://host:port`.
- **Hash-based routing (client-only).** The active game's id lives in the URL fragment: home is
`#/` (a bare page with no hash is also home) and an active game is `#/game/<id>`. This is a manual
hash sync in `App.tsx` — no react-router, no server route (every non-API GET just serves
`index.html`), so it is Traefik/static-safe. A deep link opens that exact game and two tabs can
run two different games (each has its own React state + `useSse(game.id)`). The URL hash takes
precedence over the localStorage active-game id, which remains the fallback for a bare `/` load;
a bad/missing id clears the hash to `#/` and falls back to home.
- **Batched round model.** Actions and diplomatic messages are queued in `game.pending` via
`POST /action` and `POST /diplomacy` (fast, no LLM call, no turn advance). A single
`POST /jump` ("Jump forward") commits the whole pending batch in one LLM turn: `engine.runTurn`
sends the entire `pending` array to the model, applies the `TurnResult`, clears `pending`, and
advances the turn. An empty round (no items queued) is allowed and lets the world advance on its
own. This design reduces LLM call frequency and makes each call a coherent batch of work.
Queued items can be removed again via `POST /queue/remove` (by index — the client offers a
trash button per row with a confirm dialog); like the adds it never advances the turn.
- **Monotonic `GameState.version` — clients "never go backwards".** Every mutation (queue
add/remove, turn commit) bumps `state.version` in the engine (`queueRoundItem` /
`removeRoundItem` / `applyTurnResult`); it starts at 1 on game creation, is persisted in SQLite,
and legacy games without the field are treated as version 0. ALL state ingestion on the client —
SSE `state`/`turn` events, REST responses (add/remove/jump), and game loads — flows through the
single `applyState` in `App.tsx`: a *different* game id is always adopted, the *same* game only
when the incoming version is not older than the current one (compared inside the functional
`setGame` updater, so it can't act on a stale closure). This is what stops a late-arriving stale
SSE snapshot from clobbering the UI (see Gotchas, #10).
- **Defensive turn handling, never crash.** `engine.runTurn` strips fences, extracts the first
balanced `{...}`, `JSON.parse`s, validates (ignores invalid ops, clamps stats to 0–100), retries
once with a nudge if the result is empty/invalid, and if it's still unusable emits a generic
@@ -163,6 +181,16 @@ history/
## Gotchas
- **Stale SSE snapshots can arrive *after* a fresher REST response — the version guard is what
keeps the UI honest (#10).** The SSE `state` event sent on connect/resync is a snapshot taken at
the moment the request is handled, and the SSE stream and the REST responses ride different
connections (Vite proxy in dev, Traefik in prod) with no delivery-order guarantee. A snapshot
taken just before a mutation is saved (e.g. the on-connect resync racing a quick "Add action")
can reach the browser *after* that mutation's REST response. Pre-v0.4.0 the client applied any
incoming state unconditionally, so a just-queued item could vanish ("add one: nothing happened,
add another: both appears" — issue #10). Now every state carries a monotonic `version` and
`App.tsx`'s `applyState` drops same-game states whose version is older than the current one.
Don't add unguarded `setGame` calls or remove the version comparison.
- **502s were the reasoning model exhausting its output budget.** The original client sent only
`max_tokens: 4000`; `qwen3.8-27b`'s chain-of-thought alone often exceeded 4000 tokens, so
`content` came back `null`, which the client treated as fatal → HTTP 502 (~half of turns),
+11
View File
@@ -2,6 +2,17 @@
All notable changes to **History** are listed here, newest first.
## v0.4.0 — 2026-10-11 16:02
- **Fix: queued items disappearing from the pending list (#10).** Root cause: SSE `state` events carried no freshness information, and the client's `onState`/`onTurn` handlers applied any incoming snapshot unconditionally. A stale snapshot — e.g. the SSE on-connect resync taken just before an add was saved, or an EventSource auto-reconnect — could arrive *after* the add's REST response and clobber it, so "add one: nothing happens, add another: both appear". Fix: every mutation now bumps a monotonic `GameState.version` (persisted in SQLite, started at 1 on create, bumped by `queueRoundItem`, the new `removeRoundItem`, and `applyTurnResult`; legacy games without the field are treated as version 0), and the client routes *every* state ingestion (SSE events, add/remove/jump responses, game loads) through a single "never go backwards" rule: a different game id is always adopted, the same game only when the incoming version is not older than the current one (compared inside the functional `setGame` updater, so it is stale-closure-proof).
- **New: remove button for queued items (#11).** Each pending-queue row in "Your move" has a small trash-icon button that opens a shadcn `Dialog` confirmation (item quoted, Cancel / destructive Remove, dark command-center styling). Confirming calls the new `POST /api/games/:id/queue/remove` (body `{ index }`) — same fast save → broadcast → respond order as the add routes, no LLM, no turn advance; invalid indices get a 400. The client `api.removeQueueItem(id, index)` is version-gated like every other state update, so the list can never regress after a removal.
- **Unit tests** — new `removeRoundItem` tests (valid removal + version bump, out-of-bounds/non-integer → `null`), version-bump assertions for `queueRoundItem`/`applyTurnResult`/stub turn, a legacy-no-version case, and version round-trip in `db.test.ts` (39 tests, up from 35).
- **E2E** — new "queue add is never clobbered by a stale SSE snapshot" test (one add appears immediately, still there after a 1s grace period for late SSE events, version strictly increases across adds, queue survives a full page reload) and a "remove with confirmation" test (cancel keeps the item, confirm removes it, no turn advance, version persisted) — all deterministic under `LLM_STUB=1`.
## v0.3.0 — 2026-10-11 15:29
- **Deep-linking** — the active game's id now lives in the URL (`#/game/<id>`), so a link opens that exact game and two tabs can run two different games; a bad or missing id falls back to the home screen. Fixes #3.
## v0.2.0 — 2026-10-11 14:24
- **Batched round model** — replaced the single-action/turn with a queued-round model. Actions and diplomatic messages accumulate in `game.pending` (no LLM call, no turn advance); a single "Jump forward" button commits the whole batch in one LLM turn. This fixes the 502 reliability problem by making each LLM call do one coherent batch of work instead of one tiny fragment.
+239
View File
@@ -180,3 +180,242 @@ test.describe('History — save / resume across a page refresh', () => {
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
});
});
/**
* Queue reliability — issue #10. A queued item must appear in the pending list
* right after its REST response AND stay there: before the fix, a stale SSE
* `state` snapshot (the on-connect resync taken just before the add was saved,
* carrying no freshness marker) could arrive after the REST response and
* clobber it — "add one: nothing happens, add another: both appear". The fix
* gives every mutation a monotonic `GameState.version` and the client applies
* an incoming state only when it is not older than the current one.
*/
test.describe('History — queue add is never clobbered by a stale SSE snapshot (#10)', () => {
test('one added action appears immediately and survives late SSE snapshots', async ({
page,
}) => {
test.setTimeout(90000);
const id = await createUkGame(page);
const origin = await page.evaluate(() => location.origin);
// Game screen at turn 1, nothing queued yet.
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('pending-empty')).toBeVisible();
// Queue exactly ONE action and assert it appears immediately (turn/date
// unchanged, no events yet).
await page.getByTestId('action-textarea').fill('Hold the line at the channel');
const [addResp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
),
page.getByTestId('add-action').click(),
]);
const addBody = (await addResp.json()) as { game: { version: number; pending: unknown[] } };
expect(addBody.game.pending).toHaveLength(1);
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
await expect(page.getByTestId('title-queued')).toHaveText('1 queued');
// Let any in-flight / late SSE snapshot (on-connect resync, auto-reconnect)
// have its chance to arrive. Under the stub these settle in milliseconds;
// a second is generous. Before the version fix, a stale one could clobber
// the just-added item here.
await page.waitForTimeout(1000);
// The item is still there — the stale snapshot was dropped, not applied.
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Protocol-level check: a later mutation (a second queued item) must carry
// a strictly higher version than the one we just saw.
await page.getByTestId('action-textarea').fill('Scramble the night fighters');
const [add2Resp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
),
page.getByTestId('add-action').click(),
]);
const add2Body = (await add2Resp.json()) as { game: { version: number } };
expect(add2Body.game.version).toBeGreaterThan(addBody.game.version);
await expect(page.getByTestId('pending-item')).toHaveCount(2);
// The game persisted with a version survives a full page reload.
await page.goto(`${origin}/#/game/${id}`);
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('pending-item')).toHaveCount(2, { timeout: 15000 });
});
});
/**
* Per-item removal — issue #11. Each queued row has a small trash button that
* opens a confirmation dialog; confirming removes the item server-side
* (POST /api/games/:id/queue/remove, no turn advance) and cancelling keeps it.
*/
test.describe('History — remove a queued item with confirmation (#11)', () => {
test('cancel keeps the item; confirm removes it', async ({ page }) => {
test.setTimeout(90000);
const id = await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Queue one action.
await page.getByTestId('action-textarea').fill('Open the back channel with Vichy');
await page.getByTestId('add-action').click();
await expect(page.getByTestId('pending-item')).toHaveCount(1);
// Trash icon → confirmation dialog.
await page.getByTestId('remove-queue-item').click();
const dialog = page.getByTestId('remove-confirm-dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('Open the back channel with Vichy');
// Cancel: the item stays queued.
await page.getByTestId('remove-cancel').click();
await expect(dialog).not.toBeVisible();
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Open again and confirm: the item is removed server-side.
await page.getByTestId('remove-queue-item').click();
await expect(dialog).toBeVisible();
const [removeResp] = await Promise.all([
page.waitForResponse(
(r) =>
r.request().method() === 'POST' &&
new URL(r.url()).pathname === `/api/games/${id}/queue/remove`,
),
page.getByTestId('remove-confirm').click(),
]);
const removeBody = (await removeResp.json()) as { game: { version: number; pending: unknown[] } };
expect(removeBody.game.pending).toHaveLength(0);
await expect(dialog).not.toBeVisible();
await expect(page.getByTestId('pending-empty')).toBeVisible();
await expect(page.getByTestId('title-queued')).not.toBeVisible();
// Still turn 1, no events — removing never advances the round.
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
// The bumped version is persisted with the game (it survives a reload).
const after = await page.evaluate(
async (gameId) =>
((await fetch(`/api/games/${gameId}`)).json()) as Promise<{ game: { version: number } }>,
id,
);
expect(after.game.version).toBe(removeBody.game.version);
});
});
/**
* URL deep-linking (issue #3). The active game's id lives in the URL fragment:
* home is `#/` and an active game is `#/game/<id>`. A fresh page navigated to
* that hash loads that exact game; a bad/missing id falls back to home and
* clears the hash; returning to home clears it. A browser context is fresh per
* test, so localStorage is isolated, but the e2e webServer shares one SQLite
* (`data/e2e`) across tests — so each test captures the *specific* id of the
* game it creates (via the POST /api/games response) rather than assuming "the
* only game".
*/
/**
* Start the first scenario (World War II) and pick the United Kingdom, returning
* the freshly created game's id (read from the POST /api/games response). Leaves
* the caller on the game screen at turn 1.
*/
async function createUkGame(page: Page): Promise<string> {
await page.goto('/');
await page.getByTestId('preset-card').first().waitFor({ timeout: 15000 });
await page.getByTestId('preset-card').first().getByRole('button', { name: 'Play' }).click();
await expect(page.getByTestId('world-background')).toBeVisible();
const [resp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/games',
),
page
.getByTestId('nation-card-uk')
.getByRole('button', { name: 'Play as United Kingdom' })
.click(),
]);
const body = (await resp.json()) as { game: { id: string } };
return body.game.id;
}
/** Poll until `location.hash` equals `expected` (hash writes happen in effects). */
async function waitHash(page: Page, expected: string): Promise<void> {
await page.waitForFunction(
(exp) => window.location.hash === exp,
expected,
{ timeout: 15000 },
);
}
test.describe('History — URL deep-linking (#/game/<id>)', () => {
test('creating a game puts its id in the URL', async ({ page }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
// The game screen mounts at turn 1; the URL-sync effect writes the deep link.
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
await waitHash(page, `#/game/${id}`);
});
test('a fresh page deep-links to the exact saved game', async ({ page, context }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
await waitHash(page, `#/game/${id}`);
const origin = await page.evaluate(() => location.origin);
// A brand-new page navigated straight to the deep link must load that exact
// game (matching id, preset, and player) — the URL hash takes precedence over
// the (shared) localStorage, so this is deterministic.
const page2 = await context.newPage();
await page2.goto(`${origin}/#/game/${id}`);
await expect(page2.getByTestId('world-map')).toBeVisible();
await expect(page2.getByTestId('turn-count')).toHaveText('1');
await expect(page2.getByTestId('player-nation')).toHaveText('United Kingdom');
await waitHash(page2, `#/game/${id}`);
await page2.close();
});
test('a bad game id falls back to the home screen and clears the hash', async ({
page,
context,
}) => {
test.setTimeout(60000);
await page.goto('/');
const origin = await page.evaluate(() => location.origin);
// A fresh page navigated to a non-existent id must not crash: it lands on the
// home screen and the bad hash is cleared to `#/`.
const page2 = await context.newPage();
await page2.goto(`${origin}/#/game/does-not-exist-xyz`);
await expect(page2.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
await waitHash(page2, '#/');
await page2.close();
});
test('returning to home from a game clears the hash to #/', async ({ page }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
await waitHash(page, `#/game/${id}`);
// "← Scenarios" returns to the home screen; the URL-sync effect clears the hash.
await page.getByTestId('back-to-scenarios').click();
await expect(page.getByTestId('preset-card').first()).toBeVisible({ timeout: 15000 });
await waitHash(page, '#/');
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "history",
"version": "0.2.0",
"version": "0.4.0",
"private": true,
"type": "module",
"description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).",
+6
View File
@@ -22,6 +22,7 @@ function makeState(id: string): GameState {
},
events: [],
pending: [],
version: 1,
};
}
@@ -54,6 +55,11 @@ describe('openDb', () => {
expect(db.load('a')?.events).toHaveLength(1);
expect([...db.listIds()].sort()).toEqual(['a', 'b']); // still two rows
// the monotonic version is persisted with the state and round-trips
s.version = 3;
db.save(s);
expect(db.load('a')?.version).toBe(3);
db.close();
});
+63 -1
View File
@@ -9,6 +9,7 @@ import {
isMeaningful,
parseTurnResult,
queueRoundItem,
removeRoundItem,
runTurn,
validateTurnResult,
} from './engine';
@@ -72,6 +73,7 @@ function baseState(): GameState {
},
events: [],
pending: [],
version: 1,
};
}
@@ -206,8 +208,11 @@ describe('runTurn (batched round, deterministic stub)', () => {
expect(state.events[0].nation).toBe('uk');
expect(state.events[0].outcome).toBe('success');
expect(result.stateChanges).toEqual([{ nation: 'uk', stat: 'military', op: 'set', value: 85 }]);
// committing a turn is a new revision too
expect(state.version).toBe(2);
// the original object is not mutated (a fresh queue was consumed)
expect(original.pending).toHaveLength(1);
expect(original.version).toBe(1);
});
it('an empty round: turn advances, one political "world moves on" event, pending stays empty', async () => {
@@ -286,6 +291,61 @@ describe('queueRoundItem', () => {
expect(after.pending[0]).toEqual({ kind: 'diplomacy', to: 'us', text: 'hello' });
// the original state is not mutated
expect(before.pending).toHaveLength(0);
expect(before.version).toBe(1);
});
it('bumps the monotonic version on each queue mutation', () => {
const a = queueRoundItem(baseState(), { kind: 'action', text: 'one' });
expect(a.version).toBe(2);
const b = queueRoundItem(a, { kind: 'action', text: 'two' });
expect(b.version).toBe(3);
// and never backwards: two consecutive adds from the same base both
// advance past it, so a late snapshot of the older state is always older.
const b2 = queueRoundItem(baseState(), { kind: 'action', text: 'two' });
expect(b2.version).toBe(2);
expect(b.version).toBeGreaterThan(b2.version);
});
it('treats a legacy state without a version field as version 0', () => {
const legacy = { ...baseState() } as Omit<GameState, 'version'> & { version?: number };
delete legacy.version; // a row saved before the version field existed
// simulate a legacy row coming back from SQLite: the JSON has no field.
const after = queueRoundItem(legacy as unknown as GameState, {
kind: 'action',
text: 'first after upgrade',
});
expect(after.version).toBe(1);
});
});
describe('removeRoundItem', () => {
it('removes the item at the index, bumps the version, and leaves the input untouched', () => {
const state = baseState();
state.pending = [
{ kind: 'action', text: 'one' },
{ kind: 'diplomacy', to: 'us', text: 'two' },
{ kind: 'action', text: 'three' },
];
const next = removeRoundItem(state, 1);
expect(next).not.toBeNull();
expect(next!.version).toBe(2);
expect(next!.pending).toEqual([
{ kind: 'action', text: 'one' },
{ kind: 'action', text: 'three' },
]);
expect(state.pending).toHaveLength(3); // original untouched
expect(state.version).toBe(1);
});
it('returns null for out-of-bounds or non-integer indices (route answers 400, nothing mutates)', () => {
const state = baseState();
state.pending = [{ kind: 'action', text: 'one' }];
expect(removeRoundItem(state, -1)).toBeNull();
expect(removeRoundItem(state, 1)).toBeNull(); // == length
expect(removeRoundItem(state, 5)).toBeNull();
expect(removeRoundItem(state, 0.5)).toBeNull();
expect(removeRoundItem(state, Number.NaN)).toBeNull();
expect(state.pending).toHaveLength(1); // still untouched
});
});
@@ -346,7 +406,7 @@ describe('applyTurnResult', () => {
expect(JSON.stringify(state)).toBe(snapshot);
});
it('clears the pending round on commit', () => {
it('clears the pending round on commit and bumps the version', () => {
const state = baseState();
state.pending = [{ kind: 'action', text: 'x' }];
const result: TurnResult = {
@@ -358,7 +418,9 @@ describe('applyTurnResult', () => {
};
const next = applyTurnResult(state, result);
expect(next.pending).toEqual([]);
expect(next.version).toBe(2);
expect(state.pending).toHaveLength(1); // original untouched
expect(state.version).toBe(1);
});
});
+22 -2
View File
@@ -284,6 +284,7 @@ function genericUnclearEvent(state: GameState): TurnEventSpec {
*/
export function applyTurnResult(state: GameState, result: TurnResult): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1; // every mutation is a new revision
const actingTurn = state.turn; // this action resolves the current turn
next.turn = state.turn + 1; // then we advance to the next turn to play
next.date = isDateLike(result.date) ? result.date : addDays(state.date, 2);
@@ -325,15 +326,34 @@ export function applyTurnResult(state: GameState, result: TurnResult): GameState
/**
* 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.
* round is committed via runTurn. Bumps `version` (a new revision, so stale SSE
* snapshots can never clobber it client-side). Exported so the append-without-
* advance behavior is unit-testable, and reused by the /action and /diplomacy
* routes.
*/
export function queueRoundItem(state: GameState, item: RoundItem): GameState {
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1;
next.pending.push(item);
return next;
}
/**
* Immutably remove one item from the pending round by index and return a new
* state (bumping `version`). Returns `null` when the index is missing or not a
* valid, in-bounds integer position, so the route can answer 400 without
* mutating anything. Reused by the server's /queue/remove route.
*/
export function removeRoundItem(state: GameState, index: number | undefined): GameState | null {
if (index === undefined || !Number.isInteger(index) || index < 0 || index >= state.pending.length) {
return null;
}
const next: GameState = JSON.parse(JSON.stringify(state));
next.version = (state.version ?? 0) + 1;
next.pending.splice(index, 1);
return next;
}
/**
* Run one turn end-to-end against the (injectable) LLM: build prompts, call the
* model ONCE over the whole pending batch, parse/validate, retry once with a
+24 -2
View File
@@ -9,11 +9,16 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deepClone } from '../shared/protocol';
import type { AddActionRequest, AddDiplomacyRequest, CreateGameRequest } from '../shared/protocol';
import type {
AddActionRequest,
AddDiplomacyRequest,
CreateGameRequest,
RemoveQueueItemRequest,
} from '../shared/protocol';
import type { GameState } from '../shared/types';
import { createLlmClient } from './llm';
import { makeStubLlmClient } from './stub';
import { askAdvisor, queueRoundItem, runTurn } from './engine';
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
import { buildRegionMap } from './map';
import { createPresetStore } from './presets';
import { openDb, type Db } from './db';
@@ -114,6 +119,7 @@ app.post('/api/games', (req: Request, res) => {
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
events: [],
pending: [],
version: 1, // the first saved revision; every later mutation bumps it
};
db.save(state);
res.json({ game: state });
@@ -179,6 +185,22 @@ app.post('/api/games/:id/diplomacy', (req, res) => {
res.json({ game: next });
});
// Remove one item from the pending round, by its index in `game.pending`. No
// LLM call, no turn advance — same fast save → broadcast → respond order as the
// add routes above. The mutated state bumps `version`, so clients (which apply
// states "never go backwards") always reconcile to it.
app.post('/api/games/:id/queue/remove', (req, res) => {
const id = req.params.id;
const game = db.load(id);
if (!game) return res.status(404).json({ error: 'game not found' });
const index = (req.body as RemoveQueueItemRequest | undefined)?.index;
const next = removeRoundItem(game, index);
if (!next) return res.status(400).json({ error: 'invalid queue index' });
db.save(next);
broadcast(id, 'state', { state: next });
res.json({ game: next });
});
// Jump forward: commit the whole pending round (or an empty one) to the model
// in a single turn. applyTurnResult clears the pending queue on the returned
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
+1
View File
@@ -30,6 +30,7 @@ const state: GameState = {
},
events: [],
pending: [],
version: 1,
};
describe('buildRegionMap', () => {
+8
View File
@@ -99,6 +99,14 @@ export interface TurnResponse {
result: TurnResult;
}
/** Remove one queued item from the pending round, by its index in `game.pending`. */
export interface RemoveQueueItemRequest {
index: number;
}
export interface RemoveQueueItemResponse {
game: GameState;
}
export interface AdvisorRequest {
/** Optional prompt to steer the brainstorm. */
prompt?: string;
+17
View File
@@ -85,6 +85,17 @@ export type RoundItem = {
to?: string;
};
/**
* Monotonic revision counter for a game, bumped by the server on every
* mutation (queue add/remove, turn commit) and persisted with the state.
* Clients use it to tell a fresh state from a stale one: an SSE `state`/`turn`
* snapshot (e.g. the on-connect resync) that arrives *after* a fresher REST
* response carries a lower version and must be ignored, never applied
* (see the client's "never go backwards" rule in src/App.tsx). Legacy games
* saved before this field existed are treated as version 0 by consumers.
*/
export type GameVersion = number;
/** The full, persisted state of one game. Saved to SQLite by id. */
export interface GameState {
id: string;
@@ -101,6 +112,12 @@ export interface GameState {
* batch in a single turn and clears this back to `[]`.
*/
pending: RoundItem[];
/**
* Monotonic revision counter (see GameVersion). Bumped on every mutation so
* clients can reject stale SSE snapshots; see the "never go backwards" rule
* in src/App.tsx. Starts at 1 on game creation.
*/
version: GameVersion;
}
/**
+147 -28
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,33 @@ 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);
// The single entry point for adopting a full game state — SSE `state`/`turn`
// events AND the REST responses (add/remove/jump) AND game loads all flow
// through here. Two rules:
// • a *different* game id (new game, deep link, resume) is always adopted;
// • the *same* game is adopted only when its `version` is not older than
// the current state's ("never go backwards").
// The comparison happens inside the functional updater, so it always reads
// the latest committed state — never a stale closure. This is what makes a
// late-arriving stale SSE snapshot (e.g. the on-connect resync taken just
// before an add was saved, or an EventSource auto-reconnect) harmless: it
// carries a lower version than the fresher state the REST response already
// applied, so it is dropped instead of clobbering the queued item (#10).
// Legacy games saved before `version` existed are treated as version 0.
const applyState = useCallback((s: GameState) => {
setGame((cur) => {
if (!cur || cur.id !== s.id) return s;
return (s.version ?? 0) >= (cur.version ?? 0) ? s : cur;
});
}, []);
// On the home screen: load the scenario manifest and the list of saved games
// (newest-updated first) so the user can resume a prior game.
@@ -142,28 +191,36 @@ export default function App() {
};
}, [screen.name]);
// On first mount, auto-resume the last active game (if any is stored): fetch
// its persisted state, remember it, and drop the user straight back into the
// game screen. The map is refetched by the effect below (keyed on `game`) and
// the `useSse(game?.id)` effect re-attaches the stream, resyncing from the
// server on connect. If the id is stale (404 / deleted), clear it and fall
// back to the home screen gracefully.
// On first mount, decide which game to show and load it. A deep link in the
// URL (`#/game/<id>`) takes precedence over the localStorage auto-resume; a
// bare page (no hash) falls back to the stored active game. The map is
// refetched by the effect below (keyed on `game`) and `useSse(game?.id)`
// re-attaches the stream, resyncing from the server on connect. A bad or
// missing id (404) must never crash: we clear the hash to `#/`, drop any
// stale stored id, and stay on the home screen. Once boot resolves (success
// or fallback) `bootedRef` flips so the URL-sync effect may start writing.
useEffect(() => {
const id = readActiveGameId();
if (!id) return;
const id = hashGameId(window.location.hash) ?? readActiveGameId();
if (!id) {
bootedRef.current = true; // nothing to load — activate URL syncing
return;
}
let alive = true;
(async () => {
try {
const { game } = await api.getGame(id);
if (!alive) return;
persistActiveGameId(id); // remember it so a future bare `/` load resumes it
setActiveGameId(id);
setGame(game);
applyState(game);
setScreen({ name: 'game' });
} catch {
if (alive) {
clearActiveGameId();
setActiveGameId(null);
}
if (!alive) return;
clearActiveGameId();
setActiveGameId(null);
setHomeHash(); // bad/missing id → fall back to the home screen
} finally {
bootedRef.current = true; // boot resolved — activate URL syncing
}
})();
return () => {
@@ -171,6 +228,38 @@ export default function App() {
};
}, []);
// Keep the URL in sync with the current screen: a loaded game is
// `#/game/<id>`; everything else (home and the pick screens) is `#/`. This is
// the single writer of the hash for app-driven transitions (game created,
// resumed, or leaving for home). It only writes when the desired hash differs
// from the current one, so it never triggers a redundant `hashchange`.
useEffect(() => {
if (!bootedRef.current) return;
const desired = screen.name === 'game' && game ? `#/game/${game.id}` : '#/';
if (window.location.hash !== desired) window.location.hash = desired;
}, [screen.name, game?.id]);
// React to user-driven hash changes: manual edits and browser back/forward.
// Only re-act when the parsed id actually differs from the loaded game (or the
// hash went home while we're in a game), so the writes above never loop. Uses
// `latestRef` so the mount-once effect always sees the current game + handlers.
useEffect(() => {
const onHashChange = () => {
const cur = latestRef.current;
if (!cur) return;
const id = hashGameId(window.location.hash);
if (id) {
if (id === cur.game?.id) return; // already showing this game — no-op
void cur.loadGame(id);
} else if (cur.game) {
cur.goHome(); // hash is home but we're in a game — return home
}
// else: hash is home and we're already on home/pick — nothing to do
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
// Keep the choropleth in sync with whatever state is shown (territory changes).
useEffect(() => {
if (!game) return;
@@ -181,10 +270,9 @@ export default function App() {
}, [game]);
// Resync from the persisted server state on connect and after every turn.
// Handlers only call the stable setGame, so no stale-closure concerns.
useSse(game?.id ?? null, {
onState: (s) => setGame(s),
onTurn: (s) => setGame(s),
onState: applyState,
onTurn: applyState,
});
const play = useCallback(async (presetId: string) => {
@@ -213,7 +301,7 @@ export default function App() {
// Remember this as the active game so a refresh resumes it.
persistActiveGameId(game.id);
setActiveGameId(game.id);
setGame(game);
applyState(game);
setAdvisors([]);
setScreen({ name: 'game' });
} catch (e) {
@@ -226,18 +314,20 @@ export default function App() {
);
// Add an action to the pending round. Fast, non-LLM — no `busy`; the pending
// list updates from the response and the SSE `state` event.
// list updates from the response and the SSE `state` event. Both are
// version-gated via applyState, so the item can never be clobbered by a
// stale snapshot (#10).
const runAddAction = useCallback(
async (text: string) => {
if (!game) return;
try {
const { game: g } = await api.addAction(game.id, text);
setGame(g);
applyState(g);
} catch (e) {
toastError(e);
}
},
[game],
[game, applyState],
);
// Add a diplomatic message to the pending round. Fast, non-LLM — no `busy`.
@@ -246,12 +336,27 @@ export default function App() {
if (!game) return;
try {
const { game: g } = await api.addDiplomacy(game.id, to, message);
setGame(g);
applyState(g);
} catch (e) {
toastError(e);
}
},
[game],
[game, applyState],
);
// Remove a queued item by index. Fast, non-LLM — no `busy`; the pending list
// updates from the response and the SSE `state` event (version-gated).
const runRemoveQueueItem = useCallback(
async (index: number) => {
if (!game) return;
try {
const { game: g } = await api.removeQueueItem(game.id, index);
applyState(g);
} catch (e) {
toastError(e);
}
},
[game, applyState],
);
// Jump forward: the single LLM call that commits the pending round. Only this
@@ -261,13 +366,13 @@ export default function App() {
setBusy(true);
try {
const { state } = await api.jump(game.id);
setGame(state);
applyState(state);
} catch (e) {
toastError(e);
} finally {
setBusy(false);
}
}, [game]);
}, [game, applyState]);
const askAdvisor = useCallback(
async (prompt?: string) => {
@@ -282,20 +387,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);
applyState(game);
setAdvisors([]);
setScreen({ name: 'game' });
} catch (e) {
// Deleted / not found: clear any stale reference and stay on home.
clearActiveGameId();
setActiveGameId(null);
setHomeHash();
setScreen({ name: 'home' });
toastError(e);
} finally {
@@ -303,6 +412,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);
@@ -310,6 +422,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 (
<>
@@ -322,6 +437,7 @@ export default function App() {
advisors={advisors}
onAddAction={runAddAction}
onAddDiplomacy={runAddDiplomacy}
onRemoveQueueItem={runRemoveQueueItem}
onAdvisor={askAdvisor}
onJump={runJump}
onHome={goHome}
@@ -361,6 +477,7 @@ interface GameScreenProps {
advisors: string[];
onAddAction: (a: string) => void;
onAddDiplomacy: (to: string, m: string) => void;
onRemoveQueueItem: (index: number) => void;
onAdvisor: (p?: string) => void;
onJump: () => void;
onHome: () => void;
@@ -384,6 +501,7 @@ function GameScreen({
advisors,
onAddAction,
onAddDiplomacy,
onRemoveQueueItem,
onAdvisor,
onJump,
onHome,
@@ -584,6 +702,7 @@ function GameScreen({
advisors={advisors}
onAddAction={onAddAction}
onAddDiplomacy={onAddDiplomacy}
onRemoveQueueItem={onRemoveQueueItem}
onAdvisor={onAdvisor}
onJump={onJump}
inWindow
+76 -5
View File
@@ -1,13 +1,21 @@
import { useState } from 'react';
import { Loader2, MessageSquare, Radar, Zap } from 'lucide-react';
import { Loader2, MessageSquare, Radar, Trash2, Zap } from 'lucide-react';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import type { GameState } from '@shared/types';
import type { GameState, RoundItem } from '@shared/types';
interface Props {
game: GameState;
@@ -19,6 +27,8 @@ interface Props {
onAddAction: (text: string) => void;
/** Queue a diplomatic message onto the pending round (no turn advance). */
onAddDiplomacy: (to: string, message: string) => void;
/** Remove one queued item by its index in `game.pending` (no turn advance). */
onRemoveQueueItem: (index: number) => void;
onAdvisor: (prompt?: string) => void;
/** Commit the whole pending round in a single LLM turn. */
onJump: () => void;
@@ -29,13 +39,22 @@ interface Props {
inWindow?: boolean;
}
/** One-line, display-safe rendering of a queued item (text truncated). */
function describeItem(item: RoundItem, nationName: (id?: string) => string): string {
const who = item.kind === 'diplomacy' ? ` to ${nationName(item.to)}` : '';
const text = item.text.length > 72 ? `${item.text.slice(0, 71)}…` : item.text;
return `${who ? `${who}:` : 'Action:'} “${text}”`;
}
/**
* The player's command deck ("Your move"). Three tabs — Action, 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.
* list — each row has a small trash button that removes that item (with a
* confirmation dialog) — and one prominent "Jump forward" button that commits
* the whole round in a single LLM turn. While a jump is in flight the commit
* is disabled (as is queue editing) and a hint is shown; an empty jump
* (nothing queued) is allowed and advances time.
*/
export default function ActionPanel({
game,
@@ -43,6 +62,7 @@ export default function ActionPanel({
advisors,
onAddAction,
onAddDiplomacy,
onRemoveQueueItem,
onAdvisor,
onJump,
inWindow = false,
@@ -52,6 +72,8 @@ export default function ActionPanel({
const [target, setTarget] = useState('');
const [message, setMessage] = useState('');
const [prompt, setPrompt] = useState('');
// Index of the queued item the user is about to remove, or null (dialog closed).
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
const others = Object.entries(game.nations)
.filter(([id]) => id !== game.playerNationId)
@@ -79,6 +101,10 @@ export default function ActionPanel({
};
const pending = game.pending;
// The item the confirmation dialog is about (re-derived from the live list,
// so if the queue changes while the dialog is open it tracks the current
// item at that index — or the dialog closes if the index went out of range).
const removeTarget = confirmRemoveIndex !== null ? pending[confirmRemoveIndex] : undefined;
const queuedLabel = busy
? 'Simulating…'
: `Jump forward${pending.length > 0 ? ` · ${pending.length} queued` : ''}`;
@@ -227,6 +253,18 @@ export default function ActionPanel({
</span>
)}
<span className="min-w-0 flex-1 truncate text-foreground/85">{item.text}</span>
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => setConfirmRemoveIndex(i)}
disabled={busy}
title="Remove from this round"
aria-label="Remove this queued item"
data-testid="remove-queue-item"
>
<Trash2 className="size-3.5" />
</Button>
</li>
))}
</ul>
@@ -234,6 +272,39 @@ export default function ActionPanel({
)}
</div>
{/* Confirmation for removing a queued item (never removes without asking). */}
<Dialog open={removeTarget !== undefined} onOpenChange={(open) => !open && setConfirmRemoveIndex(null)}>
<DialogContent className="max-w-sm" data-testid="remove-confirm-dialog">
<DialogHeader>
<DialogTitle>Remove queued item?</DialogTitle>
<DialogDescription>
{removeTarget ? (
<>
{describeItem(removeTarget, nationName)} will be removed from this round and
won’t be simulated on the next jump.
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmRemoveIndex(null)} data-testid="remove-cancel">
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (confirmRemoveIndex !== null) onRemoveQueueItem(confirmRemoveIndex);
setConfirmRemoveIndex(null);
}}
data-testid="remove-confirm"
>
<Trash2 className="size-4" />
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
{queuedLabel}
+8
View File
@@ -8,6 +8,7 @@ import type {
AdvisorResponse,
CreateGameResponse,
GameSummary,
RemoveQueueItemResponse,
TurnResponse,
} from '@shared/protocol';
@@ -48,6 +49,13 @@ export const api = {
method: 'POST',
body: JSON.stringify({ to, message }),
}),
// Remove one queued item from the pending round, by its index in `game.pending`.
// Fast, non-LLM — no turn advance. Returns the updated game.
removeQueueItem: (id: string, index: number) =>
req<RemoveQueueItemResponse>(`/api/games/${id}/queue/remove`, {
method: 'POST',
body: JSON.stringify({ index }),
}),
// Commit the whole pending round (or an empty one) in a single LLM turn.
jump: (id: string) =>
req<TurnResponse>(`/api/games/${id}/jump`, {