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` 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). */ 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 proposed name in an editable field; a split lists the * proposed parts (with the number of regions each is assigned) and, when the * 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({ game, open, onOpenChange, opBusy, onConfirm, onReject, }: Props) { const ops = game.pendingNationOps; const name = (ref: string) => game.nations[ref]?.name ?? ref; // Resolve a nation ref the way the server does (id first, then a unique // 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>({}); const [playerParts, setPlayerParts] = useState>({}); return ( Nation proposals The model proposed changes to nations. Confirm to apply them (you can edit a name and choose your successor state in a split) or reject to leave the world as it is. {ops.length === 0 ? (

Nothing pending — the model hasn’t proposed any nation changes.

) : (
    {ops.map((op, i) => { 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() : ''; // 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(); 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 (
  • {op.kind} {describeOp(op, name)}
    {isRename ? (
    setEdits((p) => ({ ...p, [i]: e.target.value }))} placeholder="New name…" data-testid="nation-op-name-input" />
    ) : isSplit ? (
    {(op as { parts: { name: string; territory?: string[] }[] }).parts.map((p, pi) => (
    {splitParentIsPlayer ? ( 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" /> ) : ( )} {p.name} {p.territory?.length ?? 0} region{(p.territory?.length ?? 0) === 1 ? '' : 's'}
    ))}
    {splitParentIsPlayer && (

    Choose which new state you will lead after the split.

    )} {(op as { parts: unknown[] }).parts.length < 2 && (

    This split names fewer than two parts — confirming will dismiss it without changing the world.

    )}
    ) : (
    {(op as { nationRefs: string[] }).nationRefs.map((ref, ri) => { const isSurvivor = resolveRef(ref) === survivorId; return ( {name(ref)} {isSurvivor ? ' (survivor)' : ''} ); })}
    setEdits((p) => ({ ...p, [i]: e.target.value }))} placeholder="New name…" data-testid="nation-op-name-input" /> {!mergeValid && (

    This merge is not valid as proposed — confirming will dismiss it without changing the world.

    )}
    )}
  • ); })}
)}
); }