diff --git a/AGENTS.md b/AGENTS.md index d0dca8a..2e21123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ history/ ├── eslint.config.js # flat config (ESLint 9) ├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/ ├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases -├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 127 tests) +├── vitest.config.ts # unit tests (server/**/*.test.ts + scripts/**/*.test.ts; 151 tests) ├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1) ├── index.html ├── presets/ # 5 scenario JSON files + index.json (manifest) @@ -67,10 +67,14 @@ history/ │ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests ├── shared/ # NO runtime deps — the single source of truth │ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …) -│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3) + AddAction/AddDiplomacy/RemoveQueueItem/Jump request types + pure helpers (incl. the no-man's-land map-presentation contract) +│ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and +│ # `nationOps`, v0.23 #16 P4a) + AddAction/AddDiplomacy/RemoveQueueItem/ +│ # Jump + NationOp confirm/reject request types + pure helpers ├── server/ -│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|games|map|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR -│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor +│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor| +│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads +│ │ # PORT/LLM_*/DATA_DIR +│ ├── engine.ts # pure: prompts / parse+validate / applyTurnResult (sole-ownership + no-man's-land releases, v0.22 #16 P3) / runTurn (tool path + plain fallback) / getNeighbours tool + executor / playerFrontier / queue ops / advisor / nation-op ops (resolveNationRef / applyNationOp / confirmNationOp / rejectNationOp, v0.23 #16 P4a) │ ├── llm.ts # LlmClient: chat() + chatWithTools() (bounded function-calling loop), injectable transport, retry+backoff, logging + env config │ ├── regions.ts # turf-free region-key data access: load manifest/geo/adjacency (public/ then dist/), country→keys expansion, reference table, neighboursOf/hasAdjacency │ ├── stub.ts # deterministic LLM transport (tests/e2e): one frontier conquest per turn + ADVISOR_MARKER @@ -91,7 +95,8 @@ history/ ├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens ├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight ├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier - ├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + ├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + pending-queue list (per-item trash button + confirm dialog) + "Jump forward" + "Nation proposals" trigger (v0.23 #16 P4a) + ├── NationOpDialog.tsx # review LLM-proposed nation ops (rename/split/merge) — confirm (editable rename) or reject (v0.23 #16 P4a) ├── EventFeed.tsx # scrolling event log (newest first) ├── FloatingWindow.tsx # the HUD window primitive — free-floating (draggable, position persisted), or `pinned` (fixed top-center bar), or `dock`ed (full-height left/right column); renders dock controls when `onDockChange` is set ├── StatBar.tsx @@ -105,7 +110,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 (`server/` + `scripts/`; 127 tests) | +| `npm test` | vitest unit tests (`server/` + `scripts/`; 151 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) | @@ -184,6 +189,24 @@ history/ — the distinction is decided by the client from `GameState.unowned` (via `regionFillStyle` / `unownedTooltipLabel`), never by the `RegionMap`, which only knows "owned" vs "absent". A fresh game always starts with `unowned: []` (legacy SQLite rows load with it defaulted). +- **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a).** `TurnResult.nationOps` (required + in the schema) carries the LLM’s proposed **`NationOp`s** — a discriminated union of `rename` / + `split` / `merge`. `rename` is fully applied; `split` and `merge` are **declared shapes only** + (accepted and stored as pending, but a no-op until P4b implements them). Each op names its target + via `nationRef` — the LLM may use a nation’s **id or name**; `resolveNationRef` resolves id-first, + then a case-insensitive unique-name match, and returns `null` for an unknown or ambiguous ref (such + ops are dropped defensively in `validateNationOps`, never thrown). A turn’s ops are appended to + `GameState.pendingNationOps`, which is **never cleared on a turn commit** — pending proposals persist + across turns until resolved (like the action queue). The player then decides per op via + `POST /api/games/:id/nation-op/confirm` (`{index, edits?{name?}}`) or `.../nation-op/reject` + (`{index}`): `confirmNationOp` applies the op to a clone (a rename sets `Nation.name` to the edited + value or, failing that, the proposed `to`; id / territory / relations / `playerNationId` stay valid, + so a renamed player is re-named in place) and `rejectNationOp` just drops it — both splice by index + and bump `version` once (an invalid index → `null` → HTTP 400). The response is `{ game }` and the + server broadcasts a version-bumped SSE `state`, so every tab’s version-guarded `applyState` stays in + sync. The client lists the pending ops in a “Nation proposals” dialog (in the “Your move” panel) with + an editable rename name plus Confirm / Reject. The stub never proposes a nation op (`nationOps: []`), + so this path is covered by unit tests only (`server/engine.test.ts`). - **Map click → dossier target (owner resolved from the RegionMap, by region key).** Clicking a sub-region does NOT open a popup — it switches the dossier to that sub-region's owning nation (`focusNation(ownerId, {name})` in `App.tsx`, which also records which region was @@ -209,9 +232,12 @@ history/ in `wwii-1939`) transfers nothing. Since v0.22 (#16 P3) the stub **never releases** a region to no man's land (it always returns `unowned: []` and ignores the payload's `unowned` field) — its one-conquest behavior is what the e2e assertions rely on, so the release/re-claim path is - covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). The stub answers - plain `content` (never tool calls), so it naturally bypasses the tool loop in a single round. - Unit + e2e assertions rely on these exact values, so keep the stub deterministic. + covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). Since v0.23 (#16 P4a) + the stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge), so + the confirm/reject path is covered by unit tests only, mirroring the P3 approach; all e2e + assertions are unaffected. The stub answers plain `content` (never tool calls), so it naturally + bypasses the tool loop in a single round. Unit + e2e assertions rely on these exact values, so + keep the stub deterministic. - **HUD layout.** The game screen is a full-viewport map (z-0) with `FloatingWindow` overlays. The top bar is **pinned** — a fixed, non-movable bar centered at the top a few px down (it re-centers on resize and always floats above the other windows). The **dossier / event-feed / action** diff --git a/server/engine.test.ts b/server/engine.test.ts index ad83d37..870a090 100644 --- a/server/engine.test.ts +++ b/server/engine.test.ts @@ -4,7 +4,6 @@ import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol'; import type { LlmClient, ToolDefinition, WireMessage } from './llm'; import { makeStubLlmClient } from './stub'; import { - applyNationOp, applyTurnResult, buildSystemPrompt, buildUserPrompt, diff --git a/server/index.ts b/server/index.ts index f4696f2..12de785 100644 --- a/server/index.ts +++ b/server/index.ts @@ -12,13 +12,15 @@ import { deepClone } from '../shared/protocol'; import type { AddActionRequest, AddDiplomacyRequest, + ConfirmNationOpRequest, CreateGameRequest, + RejectNationOpRequest, RemoveQueueItemRequest, } from '../shared/protocol'; import type { GameState } from '../shared/types'; import { createLlmClient } from './llm'; import { makeStubLlmClient } from './stub'; -import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine'; +import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine'; import { buildRegionMap } from './map'; import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions'; import { createPresetStore } from './presets'; @@ -228,6 +230,34 @@ app.post('/api/games/:id/queue/remove', (req, res) => { res.json({ game: next }); }); +// (v0.23, #16 P4a) Resolve a pending nation-op proposal (a rename the LLM +// proposed in an earlier turn, waiting in game.pendingNationOps). No LLM call, +// no turn advance — same fast save → broadcast → respond order as +// /queue/remove. The version bump lets "never go backwards" clients reconcile. +app.post('/api/games/:id/nation-op/confirm', (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 ConfirmNationOpRequest; + const next = confirmNationOp(game, body.index, body.edits); + if (!next) return res.status(400).json({ error: 'invalid proposal index' }); + db.save(next); + broadcast(id, 'state', { state: next }); + res.json({ game: next }); +}); + +app.post('/api/games/:id/nation-op/reject', (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 RejectNationOpRequest; + const next = rejectNationOp(game, body.index); + if (!next) return res.status(400).json({ error: 'invalid proposal 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/src/App.tsx b/src/App.tsx index 979f100..cb9bd0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -177,6 +177,10 @@ export default function App() { // gate the turn flow ("Jump forward" stays enabled while the advisor thinks) — // it only gates the advisor UI (the "Ask advisor" button + its hint). const [advisorBusy, setAdvisorBusy] = useState(false); + // Separate again: a pending nation-op confirm/reject is its own (fast, + // non-LLM) request. It gates only the nation-op dialog controls, never the + // turn flow — "Jump forward" stays usable while it is in flight. + const [opBusy, setOpBusy] = useState(false); // The id of the game the user is/was last in (mirrors localStorage) and the // list of saved games (newest first) shown on the home screen. const [activeGameId, setActiveGameId] = useState(() => readActiveGameId()); @@ -401,6 +405,43 @@ export default function App() { [game, applyState], ); + // Apply a pending nation op the model proposed (confirm, with an optional + // rename-name edit). Fast, non-LLM — gated by `opBusy`, not `busy`. The + // version-bumped state comes back in the response and the SSE `state` event. + const runConfirmNationOp = useCallback( + async (index: number, edits?: { name?: string }) => { + if (!game) return; + setOpBusy(true); + try { + const { game: g } = await api.confirmNationOp(game.id, index, edits); + applyState(g); + toast.success('Nation change applied'); + } catch (e) { + toastError(e); + } finally { + setOpBusy(false); + } + }, + [game, applyState], + ); + + // Drop a pending nation op (reject / dismiss). Fast, non-LLM — `opBusy`. + const runRejectNationOp = useCallback( + async (index: number) => { + if (!game) return; + setOpBusy(true); + try { + const { game: g } = await api.rejectNationOp(game.id, index); + applyState(g); + } catch (e) { + toastError(e); + } finally { + setOpBusy(false); + } + }, + [game, applyState], + ); + // Jump forward: the single LLM call that commits the pending round. Only this // sets `busy` (the adds above are fast and non-LLM). const runJump = useCallback(async () => { @@ -507,12 +548,15 @@ export default function App() { regions={regions} busy={busy} advisorBusy={advisorBusy} + opBusy={opBusy} advisors={advisors} onAddAction={runAddAction} onAddDiplomacy={runAddDiplomacy} onRemoveQueueItem={runRemoveQueueItem} onAdvisor={askAdvisor} onJump={runJump} + onConfirmNationOp={runConfirmNationOp} + onRejectNationOp={runRejectNationOp} onHome={goHome} /> @@ -551,12 +595,16 @@ interface GameScreenProps { busy: boolean; /** An advisor fetch is in flight — drives the advisor UI only (never the turn). */ advisorBusy: boolean; + /** A nation-op confirm/reject is in flight — drives the dialog controls only. */ + opBusy: boolean; advisors: string[]; onAddAction: (a: string) => void; onAddDiplomacy: (to: string, m: string) => void; onRemoveQueueItem: (index: number) => void; onAdvisor: (p?: string) => void; onJump: () => void; + onConfirmNationOp: (index: number, edits?: { name?: string }) => void; + onRejectNationOp: (index: number) => void; onHome: () => void; } @@ -579,12 +627,15 @@ function GameScreen({ regions, busy, advisorBusy, + opBusy, advisors, onAddAction, onAddDiplomacy, onRemoveQueueItem, onAdvisor, onJump, + onConfirmNationOp, + onRejectNationOp, onHome, }: GameScreenProps) { // Which nation's dossier to show. Defaults to the player; remounts per game. @@ -835,12 +886,15 @@ function GameScreen({ game={game} busy={busy} advisorBusy={advisorBusy} + opBusy={opBusy} advisors={advisors} onAddAction={onAddAction} onAddDiplomacy={onAddDiplomacy} onRemoveQueueItem={onRemoveQueueItem} onAdvisor={onAdvisor} onJump={onJump} + onConfirmNationOp={onConfirmNationOp} + onRejectNationOp={onRejectNationOp} inWindow /> diff --git a/src/components/ActionPanel.tsx b/src/components/ActionPanel.tsx index 18e3b43..b614631 100644 --- a/src/components/ActionPanel.tsx +++ b/src/components/ActionPanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Loader2, MessageSquare, Radar, Trash2, Zap } from 'lucide-react'; +import { Loader2, MessageSquare, PenLine, Radar, Trash2, Zap } from 'lucide-react'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; @@ -15,6 +15,7 @@ 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 NationOpDialog from './NationOpDialog'; import type { GameState, RoundItem } from '@shared/types'; interface Props { @@ -27,6 +28,11 @@ interface Props { * never disable "Jump forward" or the rest of the turn flow. */ advisorBusy: boolean; + /** + * A pending nation-op confirm/reject request is in flight. Distinct from + * `busy`: it gates only the nation-op dialog controls, never the turn flow. + */ + opBusy: boolean; /** Advisor brainstorm suggestions from the last request. */ advisors: string[]; /** Queue a free-text action onto the pending round (no turn advance). */ @@ -38,6 +44,10 @@ interface Props { onAdvisor: (prompt?: string) => void; /** Commit the whole pending round in a single LLM turn. */ onJump: () => void; + /** Apply a pending nation op; `edits.name` optionally overrides a rename. */ + onConfirmNationOp: (index: number, edits?: { name?: string }) => void; + /** Drop (reject) a pending nation op by its index. */ + onRejectNationOp: (index: number) => void; /** * Render without the outer card chrome (the surrounding floating window * provides the border and the "Your move" title). Same content, same testids. @@ -66,12 +76,15 @@ export default function ActionPanel({ game, busy, advisorBusy, + opBusy, advisors, onAddAction, onAddDiplomacy, onRemoveQueueItem, onAdvisor, onJump, + onConfirmNationOp, + onRejectNationOp, inWindow = false, }: Props) { const [tab, setTab] = useState('action'); @@ -81,6 +94,8 @@ export default function ActionPanel({ const [prompt, setPrompt] = useState(''); // Index of the queued item the user is about to remove, or null (dialog closed). const [confirmRemoveIndex, setConfirmRemoveIndex] = useState(null); + // Whether the nation-op review dialog is open. + const [opDialogOpen, setOpDialogOpen] = useState(false); const others = Object.entries(game.nations) .filter(([id]) => id !== game.playerNationId) @@ -108,6 +123,7 @@ export default function ActionPanel({ }; const pending = game.pending; + const ops = game.pendingNationOps; // 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). @@ -325,6 +341,31 @@ export default function ActionPanel({ + {/* Pending nation ops proposed by the model — open to confirm or reject. */} + {ops.length > 0 && ( + + )} + + + + + + ) : ( +
+ + {op.kind === 'split' ? 'Split' : 'Merge'} isn’t available yet — dismiss to ignore it. + + +
+ )} + + ); + })} + + )} + + + ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index be6a0c1..f60af69 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -6,9 +6,11 @@ import type { AddActionResponse, AddDiplomacyResponse, AdvisorResponse, + ConfirmNationOpResponse, CreateGameResponse, DeleteGameResponse, GameSummary, + RejectNationOpResponse, RemoveQueueItemResponse, TurnResponse, } from '@shared/protocol'; @@ -60,6 +62,21 @@ export const api = { method: 'POST', body: JSON.stringify({ index }), }), + // (v0.23, #16 P4a) Confirm a pending nation-op proposal by its index in + // `game.pendingNationOps` (a rename takes effect; `edits.name` overrides the + // proposed name). Fast, non-LLM — no turn advance. Returns the updated game. + confirmNationOp: (id: string, index: number, edits?: { name?: string }) => + req(`/api/games/${id}/nation-op/confirm`, { + method: 'POST', + body: JSON.stringify({ index, ...(edits ? { edits } : {}) }), + }), + // (v0.23, #16 P4a) Reject (drop) a pending nation-op proposal by index, + // without applying it. Fast, non-LLM — no turn advance. Returns the updated game. + rejectNationOp: (id: string, index: number) => + req(`/api/games/${id}/nation-op/reject`, { + method: 'POST', + body: JSON.stringify({ index }), + }), // Commit the whole pending round (or an empty one) in a single LLM turn. jump: (id: string) => req(`/api/games/${id}/jump`, {