feat: nation-op split/merge review — dialog (part list + successor radio, merge name), docs (P4b, #16)
This commit is contained in:
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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 } : {}) }),
|
||||||
|
|||||||
Reference in New Issue
Block a user