diff --git a/AGENTS.md b/AGENTS.md index ef3ea38..b7bc25a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 — 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) @@ -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) | @@ -117,6 +117,17 @@ history/ 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 @@ -170,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), diff --git a/CHANGELOG.md b/CHANGELOG.md index 177071c..792794e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 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/`), 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. diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts index a8d74dd..f06736f 100644 --- a/e2e/sim.spec.ts +++ b/e2e/sim.spec.ts @@ -181,6 +181,142 @@ test.describe('History — save / resume across a page refresh', () => { }); }); +/** + * 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/`. A fresh page navigated to diff --git a/package.json b/package.json index 4e8f8b2..7ff77fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "history", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "description": "AI-driven alternate-history simulator — the LLM is the world engine (Pax Historia style).", diff --git a/server/db.test.ts b/server/db.test.ts index 89c8ff3..2ac1f05 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -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(); }); diff --git a/server/engine.test.ts b/server/engine.test.ts index 3d23540..a14e012 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -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 & { 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); }); }); diff --git a/server/engine.ts b/server/engine.ts index 939579e..eba5a41 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -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 diff --git a/server/index.ts b/server/index.ts index 8a08c1a..0a8aed0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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. diff --git a/server/map.test.ts b/server/map.test.ts index a7b4b27..724001d 100644 --- a/server/map.test.ts +++ b/server/map.test.ts @@ -30,6 +30,7 @@ const state: GameState = { }, events: [], pending: [], + version: 1, }; describe('buildRegionMap', () => { diff --git a/shared/protocol.ts b/shared/protocol.ts index 53780fe..52e8fb1 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -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; diff --git a/shared/types.ts b/shared/types.ts index 54040b6..90a2db4 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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; } /** diff --git a/src/App.tsx b/src/App.tsx index 2438234..a6cadd5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -153,6 +153,26 @@ export default function App() { // 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. useEffect(() => { @@ -192,7 +212,7 @@ export default function App() { 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) return; @@ -250,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) => { @@ -282,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) { @@ -295,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`. @@ -315,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 @@ -330,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) => { @@ -362,7 +398,7 @@ export default function App() { // 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) { @@ -401,6 +437,7 @@ export default function App() { advisors={advisors} onAddAction={runAddAction} onAddDiplomacy={runAddDiplomacy} + onRemoveQueueItem={runRemoveQueueItem} onAdvisor={askAdvisor} onJump={runJump} onHome={goHome} @@ -440,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; @@ -463,6 +501,7 @@ function GameScreen({ advisors, onAddAction, onAddDiplomacy, + onRemoveQueueItem, onAdvisor, onJump, onHome, @@ -663,6 +702,7 @@ function GameScreen({ advisors={advisors} onAddAction={onAddAction} onAddDiplomacy={onAddDiplomacy} + onRemoveQueueItem={onRemoveQueueItem} onAdvisor={onAdvisor} onJump={onJump} inWindow diff --git a/src/components/ActionPanel.tsx b/src/components/ActionPanel.tsx index c5c9390..4f6d5e9 100644 --- a/src/components/ActionPanel.tsx +++ b/src/components/ActionPanel.tsx @@ -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(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({ )} {item.text} + ))} @@ -234,6 +272,39 @@ export default function ActionPanel({ )} + {/* Confirmation for removing a queued item (never removes without asking). */} + !open && setConfirmRemoveIndex(null)}> + + + Remove queued item? + + {removeTarget ? ( + <> + {describeItem(removeTarget, nationName)} will be removed from this round and + won’t be simulated on the next jump. + + ) : null} + + + + + + + + +