feat: nation-op rename review — confirm/reject routes, client dialog, AGENTS.md (P4a, #16)
Wire the 'LLM proposes, player confirms' nation-rename flow end to end: - server: POST /api/games/:id/nation-op/confirm|reject (404/400, version-bumped SSE state) - client: confirmNationOp/rejectNationOp api helpers + NationOpDialog (editable rename) wired into the Action panel via a 'Nation proposals' trigger - AGENTS.md: document the pendingNationOps mechanism, routes, nationRef resolution, stub-never-proposes
This commit is contained in:
@@ -52,7 +52,7 @@ history/
|
|||||||
├── eslint.config.js # flat config (ESLint 9)
|
├── eslint.config.js # flat config (ESLint 9)
|
||||||
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
├── tsconfig.json # strict; aliases @/ → src/, @shared/ → shared/
|
||||||
├── vite.config.ts # dev server + proxy /api,/sse → :4321; @ / @shared aliases
|
├── 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)
|
├── playwright.config.ts # deterministic e2e (webServer runs npm run dev, LLM_STUB=1)
|
||||||
├── index.html
|
├── index.html
|
||||||
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
├── presets/ # 5 scenario JSON files + index.json (manifest)
|
||||||
@@ -67,10 +67,14 @@ history/
|
|||||||
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
|
│ └── *.test.ts # turf-free vitest tests pinning the dataset + both manifests
|
||||||
├── shared/ # NO runtime deps — the single source of truth
|
├── shared/ # NO runtime deps — the single source of truth
|
||||||
│ ├── types.ts # domain types (Nation, GameState, RoundItem, Preset, …)
|
│ ├── 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/
|
├── server/
|
||||||
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|games|map|presets), /sse/:id; reads PORT/LLM_*/DATA_DIR
|
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
|
||||||
│ ├── 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/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
|
│ ├── 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
|
│ ├── 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
|
│ ├── 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
|
├── PresetPicker.tsx / NationSelect.tsx # home + nation pick screens
|
||||||
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
|
├── WorldMap.tsx # MapLibre basemap + choropleth + click→dossier + hover tooltip + player highlight
|
||||||
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
├── NationPanel.tsx # a nation's stats/territory/diplomacy dossier
|
||||||
├── ActionPanel.tsx # tabs: Action / Diplomacy / Advisor + 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)
|
├── 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
|
├── 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
|
├── StatBar.tsx
|
||||||
@@ -105,7 +110,7 @@ history/
|
|||||||
| `npm run dev` | Express on :4321 (tsx watch) + Vite on :5173 (proxies `/api`,`/sse`) concurrently |
|
| `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 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 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 typecheck` | `tsc --noEmit` (strict) across src/server/shared/e2e/configs |
|
||||||
| `npm run lint` | ESLint flat config |
|
| `npm run lint` | ESLint flat config |
|
||||||
| `npm run e2e` | Playwright, fully deterministic (`LLM_STUB=1`); **runs in CI on every PR** (see below) |
|
| `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` /
|
— 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
|
`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).
|
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).**
|
- **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
|
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
|
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
|
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
|
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
|
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
|
covered by unit tests only (`server/engine.test.ts`, `server/map.test.ts`). Since v0.23 (#16 P4a)
|
||||||
plain `content` (never tool calls), so it naturally bypasses the tool loop in a single round.
|
the stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge), so
|
||||||
Unit + e2e assertions rely on these exact values, so keep the stub deterministic.
|
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
|
- **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
|
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**
|
on resize and always floats above the other windows). The **dossier / event-feed / action**
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { makeEvent, type NationOp, type TurnResult } from '../shared/protocol';
|
|||||||
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
import type { LlmClient, ToolDefinition, WireMessage } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import {
|
import {
|
||||||
applyNationOp,
|
|
||||||
applyTurnResult,
|
applyTurnResult,
|
||||||
buildSystemPrompt,
|
buildSystemPrompt,
|
||||||
buildUserPrompt,
|
buildUserPrompt,
|
||||||
|
|||||||
+31
-1
@@ -12,13 +12,15 @@ import { deepClone } from '../shared/protocol';
|
|||||||
import type {
|
import type {
|
||||||
AddActionRequest,
|
AddActionRequest,
|
||||||
AddDiplomacyRequest,
|
AddDiplomacyRequest,
|
||||||
|
ConfirmNationOpRequest,
|
||||||
CreateGameRequest,
|
CreateGameRequest,
|
||||||
|
RejectNationOpRequest,
|
||||||
RemoveQueueItemRequest,
|
RemoveQueueItemRequest,
|
||||||
} from '../shared/protocol';
|
} from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
import type { GameState } from '../shared/types';
|
||||||
import { createLlmClient } from './llm';
|
import { createLlmClient } from './llm';
|
||||||
import { makeStubLlmClient } from './stub';
|
import { makeStubLlmClient } from './stub';
|
||||||
import { askAdvisor, queueRoundItem, removeRoundItem, runTurn } from './engine';
|
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, runTurn } from './engine';
|
||||||
import { buildRegionMap } from './map';
|
import { buildRegionMap } from './map';
|
||||||
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
|
import { allTerritoryKeys, expandCountriesToKeys, hasAdjacency, loadRegions, neighboursOf, referenceForKeys, validKeys } from './regions';
|
||||||
import { createPresetStore } from './presets';
|
import { createPresetStore } from './presets';
|
||||||
@@ -228,6 +230,34 @@ app.post('/api/games/:id/queue/remove', (req, res) => {
|
|||||||
res.json({ game: next });
|
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
|
// 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
|
// 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.
|
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
|
||||||
|
|||||||
+54
@@ -177,6 +177,10 @@ export default function App() {
|
|||||||
// gate the turn flow ("Jump forward" stays enabled while the advisor thinks) —
|
// gate the turn flow ("Jump forward" stays enabled while the advisor thinks) —
|
||||||
// it only gates the advisor UI (the "Ask advisor" button + its hint).
|
// it only gates the advisor UI (the "Ask advisor" button + its hint).
|
||||||
const [advisorBusy, setAdvisorBusy] = useState(false);
|
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
|
// 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.
|
// list of saved games (newest first) shown on the home screen.
|
||||||
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
const [activeGameId, setActiveGameId] = useState<string | null>(() => readActiveGameId());
|
||||||
@@ -401,6 +405,43 @@ export default function App() {
|
|||||||
[game, applyState],
|
[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
|
// Jump forward: the single LLM call that commits the pending round. Only this
|
||||||
// sets `busy` (the adds above are fast and non-LLM).
|
// sets `busy` (the adds above are fast and non-LLM).
|
||||||
const runJump = useCallback(async () => {
|
const runJump = useCallback(async () => {
|
||||||
@@ -507,12 +548,15 @@ export default function App() {
|
|||||||
regions={regions}
|
regions={regions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
advisorBusy={advisorBusy}
|
advisorBusy={advisorBusy}
|
||||||
|
opBusy={opBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={runAddAction}
|
onAddAction={runAddAction}
|
||||||
onAddDiplomacy={runAddDiplomacy}
|
onAddDiplomacy={runAddDiplomacy}
|
||||||
onRemoveQueueItem={runRemoveQueueItem}
|
onRemoveQueueItem={runRemoveQueueItem}
|
||||||
onAdvisor={askAdvisor}
|
onAdvisor={askAdvisor}
|
||||||
onJump={runJump}
|
onJump={runJump}
|
||||||
|
onConfirmNationOp={runConfirmNationOp}
|
||||||
|
onRejectNationOp={runRejectNationOp}
|
||||||
onHome={goHome}
|
onHome={goHome}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -551,12 +595,16 @@ interface GameScreenProps {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
|
/** An advisor fetch is in flight — drives the advisor UI only (never the turn). */
|
||||||
advisorBusy: boolean;
|
advisorBusy: boolean;
|
||||||
|
/** A nation-op confirm/reject is in flight — drives the dialog controls only. */
|
||||||
|
opBusy: boolean;
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
onAddAction: (a: string) => void;
|
onAddAction: (a: string) => void;
|
||||||
onAddDiplomacy: (to: string, m: string) => void;
|
onAddDiplomacy: (to: string, m: string) => void;
|
||||||
onRemoveQueueItem: (index: number) => void;
|
onRemoveQueueItem: (index: number) => void;
|
||||||
onAdvisor: (p?: string) => void;
|
onAdvisor: (p?: string) => void;
|
||||||
onJump: () => void;
|
onJump: () => void;
|
||||||
|
onConfirmNationOp: (index: number, edits?: { name?: string }) => void;
|
||||||
|
onRejectNationOp: (index: number) => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,12 +627,15 @@ function GameScreen({
|
|||||||
regions,
|
regions,
|
||||||
busy,
|
busy,
|
||||||
advisorBusy,
|
advisorBusy,
|
||||||
|
opBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
onRemoveQueueItem,
|
onRemoveQueueItem,
|
||||||
onAdvisor,
|
onAdvisor,
|
||||||
onJump,
|
onJump,
|
||||||
|
onConfirmNationOp,
|
||||||
|
onRejectNationOp,
|
||||||
onHome,
|
onHome,
|
||||||
}: GameScreenProps) {
|
}: GameScreenProps) {
|
||||||
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
||||||
@@ -835,12 +886,15 @@ function GameScreen({
|
|||||||
game={game}
|
game={game}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
advisorBusy={advisorBusy}
|
advisorBusy={advisorBusy}
|
||||||
|
opBusy={opBusy}
|
||||||
advisors={advisors}
|
advisors={advisors}
|
||||||
onAddAction={onAddAction}
|
onAddAction={onAddAction}
|
||||||
onAddDiplomacy={onAddDiplomacy}
|
onAddDiplomacy={onAddDiplomacy}
|
||||||
onRemoveQueueItem={onRemoveQueueItem}
|
onRemoveQueueItem={onRemoveQueueItem}
|
||||||
onAdvisor={onAdvisor}
|
onAdvisor={onAdvisor}
|
||||||
onJump={onJump}
|
onJump={onJump}
|
||||||
|
onConfirmNationOp={onConfirmNationOp}
|
||||||
|
onRejectNationOp={onRejectNationOp}
|
||||||
inWindow
|
inWindow
|
||||||
/>
|
/>
|
||||||
</FloatingWindow>
|
</FloatingWindow>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 { Badge } from './ui/badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
@@ -15,6 +15,7 @@ import { Input } from './ui/input';
|
|||||||
import { Textarea } from './ui/textarea';
|
import { Textarea } from './ui/textarea';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||||
|
import NationOpDialog from './NationOpDialog';
|
||||||
import type { GameState, RoundItem } from '@shared/types';
|
import type { GameState, RoundItem } from '@shared/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,6 +28,11 @@ interface Props {
|
|||||||
* never disable "Jump forward" or the rest of the turn flow.
|
* never disable "Jump forward" or the rest of the turn flow.
|
||||||
*/
|
*/
|
||||||
advisorBusy: boolean;
|
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. */
|
/** Advisor brainstorm suggestions from the last request. */
|
||||||
advisors: string[];
|
advisors: string[];
|
||||||
/** Queue a free-text action onto the pending round (no turn advance). */
|
/** Queue a free-text action onto the pending round (no turn advance). */
|
||||||
@@ -38,6 +44,10 @@ interface Props {
|
|||||||
onAdvisor: (prompt?: string) => void;
|
onAdvisor: (prompt?: string) => void;
|
||||||
/** Commit the whole pending round in a single LLM turn. */
|
/** Commit the whole pending round in a single LLM turn. */
|
||||||
onJump: () => void;
|
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
|
* Render without the outer card chrome (the surrounding floating window
|
||||||
* provides the border and the "Your move" title). Same content, same testids.
|
* provides the border and the "Your move" title). Same content, same testids.
|
||||||
@@ -66,12 +76,15 @@ export default function ActionPanel({
|
|||||||
game,
|
game,
|
||||||
busy,
|
busy,
|
||||||
advisorBusy,
|
advisorBusy,
|
||||||
|
opBusy,
|
||||||
advisors,
|
advisors,
|
||||||
onAddAction,
|
onAddAction,
|
||||||
onAddDiplomacy,
|
onAddDiplomacy,
|
||||||
onRemoveQueueItem,
|
onRemoveQueueItem,
|
||||||
onAdvisor,
|
onAdvisor,
|
||||||
onJump,
|
onJump,
|
||||||
|
onConfirmNationOp,
|
||||||
|
onRejectNationOp,
|
||||||
inWindow = false,
|
inWindow = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [tab, setTab] = useState('action');
|
const [tab, setTab] = useState('action');
|
||||||
@@ -81,6 +94,8 @@ export default function ActionPanel({
|
|||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
// Index of the queued item the user is about to remove, or null (dialog closed).
|
// Index of the queued item the user is about to remove, or null (dialog closed).
|
||||||
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
|
const [confirmRemoveIndex, setConfirmRemoveIndex] = useState<number | null>(null);
|
||||||
|
// Whether the nation-op review dialog is open.
|
||||||
|
const [opDialogOpen, setOpDialogOpen] = useState(false);
|
||||||
|
|
||||||
const others = Object.entries(game.nations)
|
const others = Object.entries(game.nations)
|
||||||
.filter(([id]) => id !== game.playerNationId)
|
.filter(([id]) => id !== game.playerNationId)
|
||||||
@@ -108,6 +123,7 @@ export default function ActionPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pending = game.pending;
|
const pending = game.pending;
|
||||||
|
const ops = game.pendingNationOps;
|
||||||
// The item the confirmation dialog is about (re-derived from the live list,
|
// 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
|
// 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).
|
// item at that index — or the dialog closes if the index went out of range).
|
||||||
@@ -325,6 +341,31 @@ export default function ActionPanel({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Pending nation ops proposed by the model — open to confirm or reject. */}
|
||||||
|
{ops.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpDialogOpen(true)}
|
||||||
|
disabled={busy}
|
||||||
|
data-testid="nation-op-trigger"
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2.5 text-sm hover:bg-amber-500/15"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-medium text-amber-300">
|
||||||
|
<PenLine className="size-4" />
|
||||||
|
{ops.length} nation {ops.length === 1 ? 'proposal' : 'proposals'} — review
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-amber-300/70">confirm or reject</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<NationOpDialog
|
||||||
|
game={game}
|
||||||
|
open={opDialogOpen}
|
||||||
|
onOpenChange={setOpDialogOpen}
|
||||||
|
opBusy={opBusy}
|
||||||
|
onConfirm={onConfirmNationOp}
|
||||||
|
onReject={onRejectNationOp}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button className="h-11 w-full" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
<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" />}
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Radar className="size-4" />}
|
||||||
{queuedLabel}
|
{queuedLabel}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, Loader2, PenLine, X } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import type { GameState } from '@shared/types';
|
||||||
|
import type { NationOp, RenameOp } from '@shared/protocol';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: GameState;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** A confirm/reject request is in flight — disable the per-op controls. */
|
||||||
|
opBusy: boolean;
|
||||||
|
/** Apply a pending op; `edits.name` optionally overrides a rename's target name. */
|
||||||
|
onConfirm: (index: number, edits?: { name?: string }) => void;
|
||||||
|
/** Drop a pending op (reject / dismiss). */
|
||||||
|
onReject: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short, display-safe description of a pending nation op. */
|
||||||
|
function describeOp(op: NationOp, name: (ref: string) => string): string {
|
||||||
|
if (op.kind === 'rename') return `Rename ${name(op.nationRef)} to “${op.to}”`;
|
||||||
|
if (op.kind === 'split')
|
||||||
|
return `Split ${name(op.nationRef)} into ${op.parts.length} nations (${op.parts
|
||||||
|
.map((p) => p.name)
|
||||||
|
.join(', ')})`;
|
||||||
|
return `Merge ${op.nationRefs.map(name).join(' + ')} into “${op.into}”`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviews the nation ops the LLM has proposed (stored in `pendingNationOps`)
|
||||||
|
* and lets the player apply ("LLM proposes, player confirms") or drop each one.
|
||||||
|
* A rename shows the model's proposed name in an editable field (so the player
|
||||||
|
* can tweak it before confirming); split and merge are declared but not yet
|
||||||
|
* applied (P4b) — they can be dismissed but not confirmed.
|
||||||
|
*/
|
||||||
|
export default function NationOpDialog({
|
||||||
|
game,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
opBusy,
|
||||||
|
onConfirm,
|
||||||
|
onReject,
|
||||||
|
}: Props) {
|
||||||
|
const ops = game.pendingNationOps;
|
||||||
|
const name = (ref: string) => game.nations[ref]?.name ?? ref;
|
||||||
|
// The player's edit to a rename's target name, keyed by pending-op index.
|
||||||
|
// An unset entry means "use the model's proposed name" (`op.to`).
|
||||||
|
const [edits, setEdits] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg" data-testid="nation-op-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<PenLine className="size-4 text-primary" />
|
||||||
|
Nation proposals
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The model proposed changes to nations. Confirm to apply them (you can edit a
|
||||||
|
rename) or reject to leave the world as it is.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{ops.length === 0 ? (
|
||||||
|
<p className="py-2 text-sm text-muted-foreground" data-testid="nation-op-empty">
|
||||||
|
Nothing pending — the model hasn’t proposed any nation changes.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-[50vh] space-y-2.5 overflow-y-auto pr-1" data-testid="nation-op-list">
|
||||||
|
{ops.map((op, i) => {
|
||||||
|
const isRename = op.kind === 'rename';
|
||||||
|
const finalName = isRename ? (edits[i] ?? (op as RenameOp).to).trim() : '';
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${i}-${op.kind}`}
|
||||||
|
className="rounded-lg border border-border/70 bg-card/50 p-3"
|
||||||
|
data-testid="nation-op-item"
|
||||||
|
data-kind={op.kind}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-start gap-2">
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-[11px] capitalize">
|
||||||
|
{op.kind}
|
||||||
|
</Badge>
|
||||||
|
<span className="min-w-0 flex-1 text-sm leading-snug">
|
||||||
|
{describeOp(op, name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isRename ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<Input
|
||||||
|
value={edits[i] ?? (op as RenameOp).to}
|
||||||
|
onChange={(e) => setEdits((p) => ({ ...p, [i]: e.target.value }))}
|
||||||
|
placeholder="New name…"
|
||||||
|
data-testid="nation-op-name-input"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(i)}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="nation-op-reject"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onConfirm(i, finalName ? { name: finalName } : undefined)}
|
||||||
|
disabled={opBusy}
|
||||||
|
data-testid="nation-op-confirm"
|
||||||
|
>
|
||||||
|
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{op.kind === 'split' ? 'Split' : 'Merge'} isn’t available yet — dismiss to ignore it.
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onReject(i)}
|
||||||
|
disabled={opBusy}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
data-testid="nation-op-reject"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ import type {
|
|||||||
AddActionResponse,
|
AddActionResponse,
|
||||||
AddDiplomacyResponse,
|
AddDiplomacyResponse,
|
||||||
AdvisorResponse,
|
AdvisorResponse,
|
||||||
|
ConfirmNationOpResponse,
|
||||||
CreateGameResponse,
|
CreateGameResponse,
|
||||||
DeleteGameResponse,
|
DeleteGameResponse,
|
||||||
GameSummary,
|
GameSummary,
|
||||||
|
RejectNationOpResponse,
|
||||||
RemoveQueueItemResponse,
|
RemoveQueueItemResponse,
|
||||||
TurnResponse,
|
TurnResponse,
|
||||||
} from '@shared/protocol';
|
} from '@shared/protocol';
|
||||||
@@ -60,6 +62,21 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ index }),
|
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<ConfirmNationOpResponse>(`/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<RejectNationOpResponse>(`/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.
|
// Commit the whole pending round (or an empty one) in a single LLM turn.
|
||||||
jump: (id: string) =>
|
jump: (id: string) =>
|
||||||
req<TurnResponse>(`/api/games/${id}/jump`, {
|
req<TurnResponse>(`/api/games/${id}/jump`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user