Polish map & UI into a command-center war-room; rewire live LLM endpoint

Map (WorldMap):
- Keep the near-black OpenFreeMap `dark` basemap (verified HTTP 200; the
  suggested dark-matter / dark-outdoors / liberty-dark variants 404 on that
  host) so the choropleth nation colors pop
- Stable per-nation fills at 0.8 opacity, thin subtle borders, and a brighter
  thicker amber border for the player nation so "this is me" is always obvious
- Hover brightens a region and shows a color-chipped owner + key stats popup;
  click opens a full dossier popup and focuses the left panel on that nation
- Transient, self-cleaning flash (pulsing amber outline) over regions whose
  ownership changed this turn
- Bottom-left legend auto-generated from the region map; per-scenario initial
  camera (Europe for the WWII scenarios, wider for Cold War / Modern / Aegis)

Command-center UI:
- New header bar: scenario title, prominent turn + date, the player nation
  (name + color chip), and a live Idle / "Simulating…" status indicator
- 3-column operations-console grid: nation dossier (left) / dominant map
  (center) / command deck + event feed (right); stacks map-first on mobile
- EventFeed: one card per event with a category icon, a colored outcome badge
  (success/failure/partial/neutral), a nation-colored accent edge, and the
  turn + date; new events slide in and flash at the top
- NationPanel: value-colored animated stat bars (red → green), territory as
  color-coded region chips, and diplomacy as a list with a colored indicator
  per relation (ally/rival/war/neutral)
- ActionPanel: a "Your move" composer with one prominent Send button and a
  spinner + "turns can take a few seconds" hint while a turn resolves
- sonner error toasts: a 502 / model-down surfaces as "AI temporarily
  unavailable — try again"

Live LLM rewire (server, previously uncommitted):
- Default endpoint https://ai.kkhost.pl/v1 (vLLM, no API key); reasoning model
  qwen3.8-27b; always send a generous max_tokens (4000) so it finishes its
  chain-of-thought before emitting content; read only `content`, ignore
  `reasoning`. docker-compose + AGENTS.md updated to match.

Tests: the deterministic Playwright e2e gains assertions for the dossier stat
bars, the map legend (all nations), and the event-card type icon + outcome
badge; it stays fully deterministic under LLM_STUB=1.
This commit is contained in:
2026-10-10 22:15:05 +00:00
parent e2936e85bb
commit e327e9aea0
15 changed files with 880 additions and 192 deletions
+91 -39
View File
@@ -3,7 +3,16 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/
import StatBar from './StatBar';
import { nationColor } from '@shared/protocol';
import { RELATION_COLORS } from '@/lib/colors';
import type { Nation } from '@shared/types';
import type { Nation, RelationStatus } from '@shared/types';
/** Human labels for a diplomatic status (the raw value is kept in data-attrs). */
const RELATION_LABEL: Record<RelationStatus, string> = {
ally: 'Ally',
neutral: 'Neutral',
rival: 'Rival',
hostile: 'Hostile',
war: 'At war',
};
interface Props {
nation: Nation;
@@ -13,10 +22,11 @@ interface Props {
}
/**
* A single nation's dossier: identity, the four 0–100 stat bars (colored by the
* nation's stable choropleth color), territory count, and its diplomatic posture
* toward every other nation. Used for the player (highlighted) or any inspected
* nation.
* A single nation's dossier: identity + color chip, the four 0–100 stat bars
* (colored by value: low = red → high = green, animated on change), territory as
* color-coded region chips, and diplomacy as a list with a colored indicator per
* relation (ally = green, rival = amber, war = red, neutral = slate). Used for
* the player (highlighted) or any inspected nation.
*/
export default function NationPanel({ nation, nations, isPlayer }: Props) {
const color = nationColor(nation.id);
@@ -24,67 +34,109 @@ export default function NationPanel({ nation, nations, isPlayer }: Props) {
const regions = nation.territory;
return (
<Card data-testid="nation-panel" className={isPlayer ? 'gap-4 border-primary/40' : 'gap-4'}>
<CardHeader className="flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2.5">
<span className="inline-block size-3.5 rounded-full" style={{ background: color }} />
<div className="space-y-0.5">
<CardTitle className="text-lg">{nation.name}</CardTitle>
<Card data-testid="nation-panel" className={isPlayer ? 'border-primary/40' : undefined}>
<CardHeader className="space-y-0">
<div className="flex items-center gap-3">
<span
className="inline-block size-4 shrink-0 rounded-md ring-2 ring-white/10"
style={{ background: color }}
data-testid="nation-color-chip"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<CardTitle className="truncate text-lg">{nation.name}</CardTitle>
<CardDescription>
{nation.type} · {regions.length} region{regions.length === 1 ? '' : 's'}
</CardDescription>
</div>
{isPlayer && (
<Badge variant="default" className="shrink-0">
Your nation
</Badge>
)}
</div>
<Badge variant={isPlayer ? 'default' : 'secondary'} className="shrink-0">
{isPlayer ? 'Your nation' : 'Other'}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
<StatBar label="Military" value={nation.stats.military} color={color} testId={isPlayer ? 'stat-military' : undefined} />
<StatBar label="Economy" value={nation.stats.economy} color={color} testId={isPlayer ? 'stat-economy' : undefined} />
<StatBar label="Stability" value={nation.stats.stability} color={color} testId={isPlayer ? 'stat-stability' : undefined} />
<StatBar label="Population" value={nation.stats.population} color={color} testId={isPlayer ? 'stat-population' : undefined} />
</div>
<CardContent className="space-y-5">
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Status
</h3>
<div className="space-y-2">
<StatBar
label="Military"
value={nation.stats.military}
testId={isPlayer ? 'stat-military' : undefined}
/>
<StatBar
label="Economy"
value={nation.stats.economy}
testId={isPlayer ? 'stat-economy' : undefined}
/>
<StatBar
label="Stability"
value={nation.stats.stability}
testId={isPlayer ? 'stat-stability' : undefined}
/>
<StatBar
label="Population"
value={nation.stats.population}
testId={isPlayer ? 'stat-population' : undefined}
/>
</div>
</section>
{regions.length > 0 && (
<div>
<h3 className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Territory</h3>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Territory
</h3>
{regions.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{regions.map((code) => (
<span
key={code}
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
style={{ borderColor: `${color}66`, color }}
style={{ borderColor: `${color}66`, color, background: `${color}12` }}
>
{code}
</span>
))}
</div>
</div>
)}
) : (
<p className="text-sm text-muted-foreground">No territory.</p>
)}
</section>
{relations.length > 0 && (
<div>
<h3 className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Diplomacy</h3>
<div className="flex flex-wrap gap-1.5">
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Diplomacy
</h3>
{relations.length > 0 ? (
<ul className="space-y-1.5" data-testid="relations-list">
{relations.map(([id, status]) => {
const other = nations[id];
const c = RELATION_COLORS[status] ?? '#94a3b8';
return (
<span
<li
key={id}
className="rounded-md border px-2 py-0.5 text-xs"
style={{ borderColor: `${c}66`, color: c }}
className="flex items-center gap-2 text-sm"
data-testid="relation-row"
data-status={status}
>
{other?.name ?? id}: {status}
</span>
<span className="inline-block size-2.5 shrink-0 rounded-full" style={{ background: c }} />
<span className="min-w-0 flex-1 truncate text-foreground/85">
{other?.name ?? id}
</span>
<span className="shrink-0 text-xs font-medium" style={{ color: c }}>
{RELATION_LABEL[status] ?? status}
</span>
</li>
);
})}
</div>
</div>
)}
</ul>
) : (
<p className="text-sm text-muted-foreground">No diplomatic relations yet.</p>
)}
</section>
</CardContent>
</Card>
);