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
157 lines
6.2 KiB
TypeScript
157 lines
6.2 KiB
TypeScript
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>
|
||
);
|
||
}
|