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:
+100
-40
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { CircleDot, Loader2, Radar } from 'lucide-react';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Badge } from './components/ui/badge';
|
||||
import { Separator } from './components/ui/separator';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './components/ui/select';
|
||||
import { cn } from './lib/utils';
|
||||
import PresetPicker from './components/PresetPicker';
|
||||
import NationSelect from './components/NationSelect';
|
||||
import WorldMap from './components/WorldMap';
|
||||
@@ -19,6 +19,19 @@ import type { GameState, Preset, PresetMeta } from '@shared/types';
|
||||
/** Which top-level screen the app is showing. */
|
||||
type Screen = { name: 'home' } | { name: 'pick'; preset: Preset } | { name: 'game' };
|
||||
|
||||
/**
|
||||
* Surface a turn failure. A 502 (live model down / timeout) gets a friendly,
|
||||
* actionable message; anything else passes the underlying error through.
|
||||
*/
|
||||
function toastError(e: unknown): void {
|
||||
const msg = e instanceof Error ? e.message : String(e ?? 'Something went wrong');
|
||||
if (/502|temporarily|timeout|fetch failed|network/i.test(msg)) {
|
||||
toast.error('AI temporarily unavailable — try again');
|
||||
} else {
|
||||
toast.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [screen, setScreen] = useState<Screen>({ name: 'home' });
|
||||
const [presets, setPresets] = useState<PresetMeta[]>([]);
|
||||
@@ -63,7 +76,7 @@ export default function App() {
|
||||
const { preset } = await api.getPreset(presetId);
|
||||
setScreen({ name: 'pick', preset });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load scenario');
|
||||
toastError(e);
|
||||
setScreen({ name: 'home' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -80,7 +93,7 @@ export default function App() {
|
||||
setAdvisors([]);
|
||||
setScreen({ name: 'game' });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not start the game');
|
||||
toastError(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -96,7 +109,7 @@ export default function App() {
|
||||
const { state } = await api.turn(game.id, action);
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Action failed');
|
||||
toastError(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -112,7 +125,7 @@ export default function App() {
|
||||
const { state } = await api.turn(game.id, undefined, { to, message });
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Diplomacy failed');
|
||||
toastError(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -127,7 +140,7 @@ export default function App() {
|
||||
const { state } = await api.jump(game.id);
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Jump failed');
|
||||
toastError(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -140,7 +153,7 @@ export default function App() {
|
||||
const { suggestions } = await api.advisor(game.id, prompt);
|
||||
setAdvisors(suggestions);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Advisor failed');
|
||||
toastError(e);
|
||||
}
|
||||
},
|
||||
[game],
|
||||
@@ -177,12 +190,7 @@ export default function App() {
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<NationSelect
|
||||
preset={screen.preset}
|
||||
loading={loading}
|
||||
onPick={pickNation}
|
||||
onBack={goHome}
|
||||
/>
|
||||
<NationSelect preset={screen.preset} loading={loading} onPick={pickNation} onBack={goHome} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -207,7 +215,13 @@ interface GameScreenProps {
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
/** The main play view: map + nation dossier on the left, command deck + event feed on the right. */
|
||||
/**
|
||||
* The main play view — an operations console. A top status bar carries the
|
||||
* scenario, the current turn + date, the player nation (color-chipped), and a
|
||||
* live idle/simulating indicator. Below it, a three-column grid: the nation
|
||||
* dossier (left), the dominant map (center), and the command deck + event feed
|
||||
* (right). On small screens these stack map-first.
|
||||
*/
|
||||
function GameScreen({
|
||||
game,
|
||||
regions,
|
||||
@@ -226,38 +240,70 @@ function GameScreen({
|
||||
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen max-w-[1500px] flex-col px-4 py-5 lg:px-6">
|
||||
<header className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2" data-testid="game-header">
|
||||
<div className="mx-auto flex min-h-screen max-w-[1600px] flex-col px-3 py-4 sm:px-4 lg:px-6">
|
||||
<header
|
||||
data-testid="game-header"
|
||||
className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-3 rounded-xl border bg-card/60 px-4 py-3"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={onHome}>
|
||||
← Scenarios
|
||||
</Button>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="inline-block size-3.5 rounded-full" style={{ background: nationColor(game.playerNationId) }} />
|
||||
<h1 className="text-2xl font-bold tracking-tight">{player?.name ?? game.playerNationId}</h1>
|
||||
<Badge variant="secondary">{game.presetTitle}</Badge>
|
||||
<Radar className="size-5 text-primary" />
|
||||
<div className="leading-tight">
|
||||
<h1 className="text-lg font-bold tracking-tight">History</h1>
|
||||
<p className="text-[11px] text-muted-foreground">{game.presetTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Turn <span className="font-semibold tabular-nums text-foreground" data-testid="turn-count">{game.turn}</span>
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="text-right leading-none">
|
||||
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">Turn</div>
|
||||
<div className="mt-1 text-2xl font-bold tabular-nums" data-testid="turn-count">
|
||||
{game.turn}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden h-9 w-px bg-border sm:block" />
|
||||
<div className="text-right leading-none">
|
||||
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">Date</div>
|
||||
<div className="mt-1 text-sm font-semibold tabular-nums" data-testid="game-date">
|
||||
{game.date}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden h-9 w-px bg-border sm:block" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block size-3.5 rounded-full ring-2 ring-white/10"
|
||||
style={{ background: nationColor(game.playerNationId) }}
|
||||
/>
|
||||
<span className="text-sm font-medium" data-testid="player-nation">
|
||||
{player?.name ?? game.playerNationId}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
data-testid="status-indicator"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||
busy ? 'border-primary/40 bg-primary/10 text-primary' : 'border-border bg-muted/40 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<CircleDot className="size-3" />
|
||||
)}
|
||||
{busy ? 'Simulating…' : 'Idle'}
|
||||
</span>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="tabular-nums" data-testid="game-date">
|
||||
{game.date}
|
||||
</span>
|
||||
{busy && <span className="animate-pulse text-primary">· simulating…</span>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid flex-1 gap-4 lg:grid-cols-[2fr_1fr]">
|
||||
<section className="min-w-0 space-y-4">
|
||||
<div className="h-[62vh] min-h-[420px] overflow-hidden rounded-xl border">
|
||||
<WorldMap regions={regions} playerNationId={game.playerNationId} className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Inspect nation</span>
|
||||
<main className="grid flex-1 gap-4 lg:grid-cols-[300px_minmax(0,1fr)_340px]">
|
||||
{/* Left: nation dossier (mobile order 3). */}
|
||||
<section className="order-3 min-w-0 lg:order-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Inspect</span>
|
||||
<Select value={viewingId} onValueChange={setViewingId}>
|
||||
<SelectTrigger className="w-56" data-testid="inspect-select">
|
||||
<SelectTrigger className="w-full" data-testid="inspect-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -269,11 +315,25 @@ function GameScreen({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} />
|
||||
</section>
|
||||
|
||||
<aside className="flex min-w-0 flex-col gap-4">
|
||||
{/* Center: the dominant map (mobile order 1). */}
|
||||
<section className="order-1 min-w-0 lg:order-2">
|
||||
<div className="relative h-[58vh] min-h-[420px] overflow-hidden rounded-xl border lg:h-[80vh]">
|
||||
<WorldMap
|
||||
regions={regions}
|
||||
playerNationId={game.playerNationId}
|
||||
nations={game.nations}
|
||||
presetId={game.presetId}
|
||||
onFocusNation={setViewingId}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Right: command deck + event feed (mobile order 2). */}
|
||||
<aside className="order-2 flex min-h-0 min-w-0 flex-col gap-4 lg:order-3">
|
||||
<ActionPanel
|
||||
game={game}
|
||||
busy={busy}
|
||||
@@ -283,7 +343,7 @@ function GameScreen({
|
||||
onAdvisor={onAdvisor}
|
||||
onJump={onJump}
|
||||
/>
|
||||
<div className="h-[46vh] min-h-[280px]" data-testid="event-feed-slot">
|
||||
<div className="min-h-[360px] flex-1 lg:min-h-0" data-testid="event-feed-slot">
|
||||
<EventFeed events={game.events} nations={game.nations} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user