308 lines
14 KiB
TypeScript
308 lines
14 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` 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<Record<number, string>>({});
|
||
const [playerParts, setPlayerParts] = useState<Record<number, number>>({});
|
||
|
||
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
|
||
name and choose your successor state in a split) 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 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<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 (
|
||
<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={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}
|
||
data-testid="nation-op-confirm"
|
||
>
|
||
{opBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||
Confirm
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2.5">
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{(op as { nationRefs: string[] }).nationRefs.map((ref, ri) => {
|
||
const isSurvivor = resolveRef(ref) === survivorId;
|
||
return (
|
||
<Badge
|
||
key={ri}
|
||
variant={isSurvivor ? 'default' : 'secondary'}
|
||
className="text-[11px]"
|
||
data-testid="nation-op-input"
|
||
>
|
||
{name(ref)}
|
||
{isSurvivor ? ' (survivor)' : ''}
|
||
</Badge>
|
||
);
|
||
})}
|
||
</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>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|