feat: nation-op split/merge review — dialog (part list + successor radio, merge name), docs (P4b, #16)

This commit is contained in:
2026-10-14 13:42:46 +00:00
parent 458e9be419
commit 14c5d4881d
5 changed files with 239 additions and 60 deletions
+44 -24
View File
@@ -68,13 +68,13 @@ history/
├── 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, and │ └── protocol.ts # LLM TurnResult schema (incl. `unowned`, v0.22 #16 P3, and
│ # `nationOps`, v0.23 #16 P4a) + AddAction/AddDiplomacy/RemoveQueueItem/ │ # `nationOps`, v0.23 #16 P4a; v0.24 #16 P4b) + AddAction/AddDiplomacy/RemoveQueueItem/
│ # Jump + NationOp confirm/reject request types + pure helpers │ # Jump + NationOp confirm/reject request types + pure helpers
├── server/ ├── server/
│ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor| │ ├── index.ts # Express: /api/* (health|action|diplomacy|queue/remove|jump|advisor|
│ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads │ │ # nation-op/confirm|nation-op/reject|games|map|presets), /sse/:id; reads
│ │ # PORT/LLM_*/DATA_DIR │ │ # 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) │ ├── 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 / freshNationId / NEW_NATION_STATS / applyNationOp rename+split+merge / confirmNationOp / rejectNationOp, v0.23 #16 P4a; v0.24 #16 P4b)
│ ├── 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
@@ -96,7 +96,7 @@ history/
├── 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" + "Nation proposals" trigger (v0.23 #16 P4a) ├── 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) ├── NationOpDialog.tsx # review LLM-proposed nation ops (rename/split/merge) — confirm (editable rename/merge name, split successor radio) or reject (v0.24 #16 P4b)
├── 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
@@ -189,24 +189,44 @@ 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 - **Nation ops — “LLM proposes, player confirms” (v0.23, #16 P4a; v0.24, #16 P4b).**
in the schema) carries the LLM’s proposed **`NationOp`s** — a discriminated union of `rename` / `TurnResult.nationOps` (required in the schema) carries the LLM’s proposed **`NationOp`s** — a
`split` / `merge`. `rename` is fully applied; `split` and `merge` are **declared shapes only** discriminated union of `rename` / `split` / `merge`, all three of which are now applied on confirm
(accepted and stored as pending, but a no-op until P4b implements them). Each op names its target (P4b implemented split/merge). Each op names its target via `nationRef` — the LLM may use a
via `nationRef` — the LLM may use a nation’s **id or name**; `resolveNationRef` resolves id-first, nation’s **id or name**; `resolveNationRef` resolves id-first, then a case-insensitive
then a case-insensitive unique-name match, and returns `null` for an unknown or ambiguous ref (such unique-name match, and returns `null` for an unknown or ambiguous ref (such ops are dropped
ops are dropped defensively in `validateNationOps`, never thrown). A turn’s ops are appended to 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 `GameState.pendingNationOps`, which is **never cleared on a turn commit** — pending proposals
across turns until resolved (like the action queue). The player then decides per op via 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` `POST /api/games/:id/nation-op/confirm` (`{index, edits?{name?, playerPart?}}`) or
(`{index}`): `confirmNationOp` applies the op to a clone (a rename sets `Nation.name` to the edited `.../nation-op/reject` (`{index}`): `confirmNationOp` applies the op to a clone — **rename** sets
value or, failing that, the proposed `to`; id / territory / relations / `playerNationId` stay valid, `Nation.name` to the edited value or, failing that, the proposed `to` (id / territory / relations /
so a renamed player is re-named in place) and `rejectNationOp` just drops it — both splice by index `playerNationId` stay valid, so a renamed player is re-named in place); **split** (the parent
and bump `version` once (an invalid index → `null` → HTTP 400). The response is `{ game }` and the must resolve and have ≥2 named parts, else a no-op) partitions the parent’s region-key territory
server broadcasts a version-bumped SSE `state`, so every tab’s version-guarded `applyState` stays in into new nations in parts order — each new nation gets a fresh id (`freshNationId`: first free of
sync. The client lists the pending ops in a “Nation proposals” dialog (in the “Your move” panel) with `n1`, `n2`, …) and the baseline `NEW_NATION_STATS` (not a copy of the parent’s), plus a copy of
an editable rename name plus Confirm / Reject. The stub never proposes a nation op (`nationOps: []`), the parent’s relations; a key named by several parts goes to the first, and the parent’s
so this path is covered by unit tests only (`server/engine.test.ts`). unclaimed keys go to part 0 (in territory order), so sole ownership holds and nothing is
orphaned; if the parent was the player’s nation, `playerNationId` moves to the part chosen by
`edits.playerPart` (clamped into range, default 0), which also receives `edits.name`; **merge**
(every ref must resolve, ≥2 DISTINCT inputs, and the survivor must be one of them, else a
no-op) folds the inputs into the survivor, which keeps its id and its own stats, gains the
union of the inputs’ territories (the survivor’s keys first) and a union of their relations
(the survivor’s own view of a third party wins, else the first input’s), is renamed
`edits.name` → `into` → its own name, while the other inputs are removed and their ids dropped
from every remaining nation’s relations maps; if the player led a removed input,
`playerNationId` moves to the survivor. An op that is invalid at confirm time (an ambiguous ref,
…) is a no-op on the world but is still removed — 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): a rename and a merge show an editable name field
(the merge also highlights the survivor among the input chips); a split lists each part (name +
proposed region count) with a successor radio that appears only when the parent is the player’s
own nation (preselected part 0 — the choice is always valid). The system prompt documents all
three op shapes as a proposal queue (at most 1–2 per turn, each narrated in events). 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
@@ -233,9 +253,9 @@ history/
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`). Since v0.23 (#16 P4a) 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 stub also returns `nationOps: []` — it **never proposes** a nation op (rename/split/merge;
the confirm/reject path is covered by unit tests only, mirroring the P3 approach; all e2e still true since P4b implemented split/merge), so the confirm/reject path is covered by unit
assertions are unaffected. The stub answers plain `content` (never tool calls), so it naturally 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 bypasses the tool loop in a single round. Unit + e2e assertions rely on these exact values, so
keep the stub deterministic. 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
+6 -5
View File
@@ -405,11 +405,12 @@ export default function App() {
[game, applyState], [game, applyState],
); );
// Apply a pending nation op the model proposed (confirm, with an optional // Apply a pending nation op the model proposed (confirm; `edits.name`
// rename-name edit). Fast, non-LLM — gated by `opBusy`, not `busy`. The // overrides a rename/merge name, `edits.playerPart` picks the player's
// version-bumped state comes back in the response and the SSE `state` event. // successor part in a split). 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( const runConfirmNationOp = useCallback(
async (index: number, edits?: { name?: string }) => { async (index: number, edits?: { name?: string; playerPart?: number }) => {
if (!game) return; if (!game) return;
setOpBusy(true); setOpBusy(true);
try { try {
@@ -603,7 +604,7 @@ interface GameScreenProps {
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; onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
onRejectNationOp: (index: number) => void; onRejectNationOp: (index: number) => void;
onHome: () => void; onHome: () => void;
} }
+6 -2
View File
@@ -44,8 +44,12 @@ 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; * Apply a pending nation op: `edits.name` overrides a rename's target name
* (or a merge's name); `edits.playerPart` picks the player's successor part
* in a split (default 0).
*/
onConfirmNationOp: (index: number, edits?: { name?: string; playerPart?: number }) => void;
/** Drop (reject) a pending nation op by its index. */ /** Drop (reject) a pending nation op by its index. */
onRejectNationOp: (index: number) => void; onRejectNationOp: (index: number) => void;
/** /**
+176 -25
View File
@@ -19,8 +19,12 @@ interface Props {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** A confirm/reject request is in flight — disable the per-op controls. */ /** A confirm/reject request is in flight — disable the per-op controls. */
opBusy: boolean; opBusy: boolean;
/** Apply a pending op; `edits.name` optionally overrides a rename's target name. */ /**
onConfirm: (index: number, edits?: { name?: string }) => void; * Apply a pending op: `edits.name` overrides a rename's target name (or the
* merged name for a merge); `edits.playerPart` picks which new part the
* player's nation becomes in a split (default 0).
*/
onConfirm: (index: number, edits?: { name?: string; playerPart?: number }) => void;
/** Drop a pending op (reject / dismiss). */ /** Drop a pending op (reject / dismiss). */
onReject: (index: number) => void; onReject: (index: number) => void;
} }
@@ -37,10 +41,12 @@ function describeOp(op: NationOp, name: (ref: string) => string): string {
/** /**
* Reviews the nation ops the LLM has proposed (stored in `pendingNationOps`) * Reviews the nation ops the LLM has proposed (stored in `pendingNationOps`)
* and lets the player apply ("LLM proposes, player confirms") or drop each one. * 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 * a rename shows the proposed name in an editable field; a split lists the
* can tweak it before confirming); split and merge are declared but not yet * proposed parts (with the number of regions each is assigned) and, when the
* applied (P4b) — they can be dismissed but not confirmed. * split parent is the player's own nation, radio buttons to pick which new
* part the player leads; a merge shows the input nations (the survivor
* highlighted) and an editable name for the merged state (default `into`).
*/ */
export default function NationOpDialog({ export default function NationOpDialog({
game, game,
@@ -52,9 +58,24 @@ export default function NationOpDialog({
}: Props) { }: Props) {
const ops = game.pendingNationOps; const ops = game.pendingNationOps;
const name = (ref: string) => game.nations[ref]?.name ?? ref; const name = (ref: string) => game.nations[ref]?.name ?? ref;
// The player's edit to a rename's target name, keyed by pending-op index. // Resolve a nation ref the way the server does (id first, then a unique
// An unset entry means "use the model's proposed name" (`op.to`). // case-insensitive name; null when unknown/ambiguous) — UI hints only, the
// server stays authoritative.
const resolveRef = (ref: string): string | null => {
const r = (ref ?? '').trim();
if (!r) return null;
if (game.nations[r]) return r;
const matches = Object.values(game.nations).filter(
(n) => n.name.trim().toLowerCase() === r.toLowerCase(),
);
return matches.length === 1 ? matches[0].id : null;
};
// The player's edits, keyed by pending-op index: the rename/merge target
// name (an unset entry means "use the model's proposed name"), and which
// split part the player succeeds to (preselected 0 — the radio is
// "required" and always has a valid answer).
const [edits, setEdits] = useState<Record<number, string>>({}); const [edits, setEdits] = useState<Record<number, string>>({});
const [playerParts, setPlayerParts] = useState<Record<number, number>>({});
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -68,7 +89,8 @@ export default function NationOpDialog({
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
The model proposed changes to nations. Confirm to apply them (you can edit a The model proposed changes to nations. Confirm to apply them (you can edit a
rename) or reject to leave the world as it is. name and choose your successor state in a split) or reject to leave the world
as it is.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -80,7 +102,34 @@ export default function NationOpDialog({
<ul className="max-h-[50vh] space-y-2.5 overflow-y-auto pr-1" data-testid="nation-op-list"> <ul className="max-h-[50vh] space-y-2.5 overflow-y-auto pr-1" data-testid="nation-op-list">
{ops.map((op, i) => { {ops.map((op, i) => {
const isRename = op.kind === 'rename'; const isRename = op.kind === 'rename';
const isSplit = op.kind === 'split';
const isMerge = op.kind === 'merge';
const finalName = isRename ? (edits[i] ?? (op as RenameOp).to).trim() : ''; const finalName = isRename ? (edits[i] ?? (op as RenameOp).to).trim() : '';
// Split: is the parent the player's own nation? (the successor radio
// is offered only in that case — the engine ignores playerPart otherwise)
const splitParentIsPlayer =
isSplit && resolveRef((op as { nationRef: string }).nationRef) === game.playerNationId;
const playerPart = playerParts[i] ?? 0;
// Merge: the survivor (highlighted) and whether the proposal is
// even valid (2+ distinct inputs; the survivor among them).
const survivorId = isMerge ? resolveRef((op as { survivingRef: string }).survivingRef) : null;
const inputIds = isMerge
? new Set(
(op as { nationRefs: string[] }).nationRefs
.map(resolveRef)
.filter((x): x is string => x !== null),
)
: new Set<string>();
const mergeValid =
!isMerge || (inputIds.size >= 2 && (survivorId !== null && inputIds.has(survivorId)));
const mergeName = isMerge ? (edits[i] ?? (op as { into: string }).into).trim() : '';
const confirm = () => {
if (isRename) onConfirm(i, finalName ? { name: finalName } : undefined);
else if (isMerge) onConfirm(i, mergeName ? { name: mergeName } : undefined);
else onConfirm(i, splitParentIsPlayer ? { playerPart } : undefined);
};
return ( return (
<li <li
key={`${i}-${op.kind}`} key={`${i}-${op.kind}`}
@@ -96,6 +145,7 @@ export default function NationOpDialog({
{describeOp(op, name)} {describeOp(op, name)}
</span> </span>
</div> </div>
{isRename ? ( {isRename ? (
<div className="space-y-2.5"> <div className="space-y-2.5">
<Input <Input
@@ -118,7 +168,72 @@ export default function NationOpDialog({
</Button> </Button>
<Button <Button
size="sm" size="sm"
onClick={() => onConfirm(i, finalName ? { name: finalName } : undefined)} onClick={confirm}
disabled={opBusy}
data-testid="nation-op-confirm"
>
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Confirm
</Button>
</div>
</div>
) : isSplit ? (
<div className="space-y-2.5">
<div className="space-y-1">
{(op as { parts: { name: string; territory?: string[] }[] }).parts.map((p, pi) => (
<div
key={pi}
className="flex items-center gap-2 text-sm"
data-testid="nation-op-part"
data-part={pi}
>
{splitParentIsPlayer ? (
<input
type="radio"
name={`player-part-${i}`}
checked={playerPart === pi}
onChange={() => setPlayerParts((prev) => ({ ...prev, [i]: pi }))}
disabled={opBusy}
className="size-3.5 shrink-0 accent-amber-400"
aria-label={`Make ${p.name} the player's successor state`}
data-testid="nation-op-player-part"
/>
) : (
<span className="size-3.5 shrink-0" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate">{p.name}</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{p.territory?.length ?? 0} region{(p.territory?.length ?? 0) === 1 ? '' : 's'}
</span>
</div>
))}
</div>
{splitParentIsPlayer && (
<p className="text-[11px] text-muted-foreground" data-testid="nation-op-split-hint">
Choose which new state you will lead after the split.
</p>
)}
{(op as { parts: unknown[] }).parts.length < 2 && (
<p className="text-[11px] text-muted-foreground">
This split names fewer than two parts — confirming will dismiss it
without changing the world.
</p>
)}
<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={confirm}
disabled={opBusy} disabled={opBusy}
data-testid="nation-op-confirm" data-testid="nation-op-confirm"
> >
@@ -128,21 +243,57 @@ export default function NationOpDialog({
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between gap-2"> <div className="space-y-2.5">
<span className="text-[11px] text-muted-foreground"> <div className="flex flex-wrap gap-1.5">
{op.kind === 'split' ? 'Split' : 'Merge'} isn’t available yet — dismiss to ignore it. {(op as { nationRefs: string[] }).nationRefs.map((ref, ri) => {
</span> const isSurvivor = resolveRef(ref) === survivorId;
<Button return (
variant="ghost" <Badge
size="sm" key={ri}
onClick={() => onReject(i)} variant={isSurvivor ? 'default' : 'secondary'}
disabled={opBusy} className="text-[11px]"
className="text-muted-foreground hover:text-destructive" data-testid="nation-op-input"
data-testid="nation-op-reject" >
> {name(ref)}
<X className="size-3.5" /> {isSurvivor ? ' (survivor)' : ''}
Dismiss </Badge>
</Button> );
})}
</div>
<Input
value={edits[i] ?? (op as { into: string }).into}
onChange={(e) => setEdits((p) => ({ ...p, [i]: e.target.value }))}
placeholder="New name…"
data-testid="nation-op-name-input"
/>
{!mergeValid && (
<p className="text-[11px] text-muted-foreground">
This merge is not valid as proposed — confirming will dismiss it
without changing the world.
</p>
)}
<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={confirm}
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>
)} )}
</li> </li>
+7 -4
View File
@@ -62,10 +62,13 @@ 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 // (v0.23, #16 P4a; v0.24, #16 P4b) Confirm a pending nation-op proposal by
// `game.pendingNationOps` (a rename takes effect; `edits.name` overrides the // its index in `game.pendingNationOps`: a rename takes effect (`edits.name`
// proposed name). Fast, non-LLM — no turn advance. Returns the updated game. // overrides the proposed name), a split partitions the nation's territory
confirmNationOp: (id: string, index: number, edits?: { name?: string }) => // (`edits.playerPart` picks which part the player leads, default 0), and a
// merge folds its inputs into the survivor (`edits.name` overrides the
// merged name). Fast, non-LLM — no turn advance. Returns the updated game.
confirmNationOp: (id: string, index: number, edits?: { name?: string; playerPart?: number }) =>
req<ConfirmNationOpResponse>(`/api/games/${id}/nation-op/confirm`, { req<ConfirmNationOpResponse>(`/api/games/${id}/nation-op/confirm`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ index, ...(edits ? { edits } : {}) }), body: JSON.stringify({ index, ...(edits ? { edits } : {}) }),