import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { Activity, BookOpen, CircleDot, ListTree, Loader2, Map as MapIcon, Radar, } from 'lucide-react'; import { Toaster } from './components/ui/sonner'; import { Button } from './components/ui/button'; import { Badge } from './components/ui/badge'; 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'; import FloatingWindow from './components/FloatingWindow'; import MapLegend from './components/MapLegend'; import NationPanel from './components/NationPanel'; import EventFeed from './components/EventFeed'; import ActionPanel from './components/ActionPanel'; import { api } from './lib/api'; import { useSse } from './lib/sse'; import { nationColor, type GameSummary, type RegionMap } from '@shared/protocol'; 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); } } // --------------------------------------------------------------------------- // Floating-window collapse + dock persistence. // (Free-floating *positions* live in FloatingWindow itself, per id.) // --------------------------------------------------------------------------- const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed'; const FLOAT_DOCK_KEY = 'history.floatwin.v1.dock'; /** * Every collapsible HUD panel (the slim, pinned header is never collapsed). * The three substantive panels (dossier / feed / action) are also dockable; * the legend stays a small floating widget, and there is no longer a "Map" * window. */ const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend'] as const; function loadCollapsedMap(): Record { try { const raw = localStorage.getItem(FLOAT_COLLAPSED_KEY); if (raw == null) return {}; const v: unknown = JSON.parse(raw); if (!v || typeof v !== 'object' || Array.isArray(v)) return {}; const out: Record = {}; for (const [k, val] of Object.entries(v as Record)) { if (typeof val === 'boolean') out[k] = val; } return out; } catch { return {}; } } function persistCollapsedMap(m: Record): void { try { localStorage.setItem(FLOAT_COLLAPSED_KEY, JSON.stringify(m)); } catch { /* storage unavailable — collapse state just won't persist */ } } /** A dock mode: a docked edge, or 'float' (free-floating, the default). */ type DockMode = 'left' | 'right' | 'float'; function loadDockMap(): Record { try { const raw = localStorage.getItem(FLOAT_DOCK_KEY); if (raw == null) return {}; const v: unknown = JSON.parse(raw); if (!v || typeof v !== 'object' || Array.isArray(v)) return {}; const out: Record = {}; for (const [k, val] of Object.entries(v as Record)) { if (val === 'left' || val === 'right' || val === 'float') out[k] = val; } return out; } catch { return {}; } } function persistDockMap(m: Record): void { try { localStorage.setItem(FLOAT_DOCK_KEY, JSON.stringify(m)); } catch { /* storage unavailable — dock choices just won't persist */ } } // --------------------------------------------------------------------------- // Active-game persistence. The id of the game the user is (or was last) playing // is remembered in localStorage, so a page refresh drops them straight back // into their game, and the home screen can offer to resume it explicitly. // --------------------------------------------------------------------------- const ACTIVE_GAME_KEY = 'history.activeGameId'; function readActiveGameId(): string | null { try { const v = localStorage.getItem(ACTIVE_GAME_KEY); return v && v.length > 0 ? v : null; } catch { return null; } } function persistActiveGameId(id: string): void { try { localStorage.setItem(ACTIVE_GAME_KEY, id); } catch { /* storage unavailable — the game just won't auto-resume */ } } function clearActiveGameId(): void { try { localStorage.removeItem(ACTIVE_GAME_KEY); } catch { /* storage unavailable */ } } // --------------------------------------------------------------------------- // Hash routing. The active game's id lives in the URL fragment: home is `#/` // (a bare page with no hash is also home) and an active game is `#/game/`. // This manual hash sync — no react-router, no server-side route — fits the // existing state-based router and is static/Traefik safe, because the fragment // never reaches the server (every non-API GET just serves `index.html`). The // localStorage active-game id above stays as the fallback for a bare `/` load; // the URL hash takes precedence when present. // --------------------------------------------------------------------------- /** The game id encoded in `#/game/`, or `null` when the hash is home/bare. */ function hashGameId(hash: string): string | null { const m = hash.match(/^#\/game\/(.+)$/); return m && m[1].length > 0 ? m[1] : null; } /** Write the home route (`#/`) into the URL hash unless it is already home/bare. */ function setHomeHash(): void { const h = window.location.hash; if (h !== '' && h !== '#/') window.location.hash = '#/'; } export default function App() { const [screen, setScreen] = useState({ name: 'home' }); const [presets, setPresets] = useState([]); const [loading, setLoading] = useState(false); const [game, setGame] = useState(null); const [regions, setRegions] = useState({}); const [advisors, setAdvisors] = useState([]); const [busy, setBusy] = useState(false); // Separate from `busy`: an advisor fetch is its own LLM call. It must NOT // gate the turn flow ("Jump forward" stays enabled while the advisor thinks) — // it only gates the advisor UI (the "Ask advisor" button + its hint). const [advisorBusy, setAdvisorBusy] = useState(false); // The id of the game the user is/was last in (mirrors localStorage) and the // list of saved games (newest first) shown on the home screen. const [activeGameId, setActiveGameId] = useState(() => readActiveGameId()); const [savedGames, setSavedGames] = useState([]); // Once the boot fetch resolves (success or fallback), allow the URL-sync effect // to start writing the hash. Until then, a deep link in the URL must not be // clobbered by an app-driven "home" write during the initial render. const bootedRef = useRef(false); // Latest game + handlers, read by the (mount-once) hashchange listener so it // never acts on a stale closure. Written each render (same pattern as sse.ts). const latestRef = useRef<{ game: GameState | null; goHome: () => void; loadGame: (id: string) => void } | null>(null); // The single entry point for adopting a full game state — SSE `state`/`turn` // events AND the REST responses (add/remove/jump) AND game loads all flow // through here. Two rules: // • a *different* game id (new game, deep link, resume) is always adopted; // • the *same* game is adopted only when its `version` is not older than // the current state's ("never go backwards"). // The comparison happens inside the functional updater, so it always reads // the latest committed state — never a stale closure. This is what makes a // late-arriving stale SSE snapshot (e.g. the on-connect resync taken just // before an add was saved, or an EventSource auto-reconnect) harmless: it // carries a lower version than the fresher state the REST response already // applied, so it is dropped instead of clobbering the queued item (#10). // Legacy games saved before `version` existed are treated as version 0. const applyState = useCallback((s: GameState) => { setGame((cur) => { if (!cur || cur.id !== s.id) return s; return (s.version ?? 0) >= (cur.version ?? 0) ? s : cur; }); }, []); // On the home screen: load the scenario manifest and the list of saved games // (newest-updated first) so the user can resume a prior game. useEffect(() => { if (screen.name !== 'home') return; let alive = true; api .listPresets() .then((r) => alive && setPresets(r.presets)) .catch(() => {}); api .listGames() .then((r) => alive && setSavedGames(r.games)) .catch(() => {}); return () => { alive = false; }; }, [screen.name]); // On first mount, decide which game to show and load it. A deep link in the // URL (`#/game/`) takes precedence over the localStorage auto-resume; a // bare page (no hash) falls back to the stored active game. The map is // refetched by the effect below (keyed on `game`) and `useSse(game?.id)` // re-attaches the stream, resyncing from the server on connect. A bad or // missing id (404) must never crash: we clear the hash to `#/`, drop any // stale stored id, and stay on the home screen. Once boot resolves (success // or fallback) `bootedRef` flips so the URL-sync effect may start writing. useEffect(() => { const id = hashGameId(window.location.hash) ?? readActiveGameId(); if (!id) { bootedRef.current = true; // nothing to load — activate URL syncing return; } let alive = true; (async () => { try { const { game } = await api.getGame(id); if (!alive) return; persistActiveGameId(id); // remember it so a future bare `/` load resumes it setActiveGameId(id); applyState(game); setScreen({ name: 'game' }); } catch { if (!alive) return; clearActiveGameId(); setActiveGameId(null); setHomeHash(); // bad/missing id → fall back to the home screen } finally { bootedRef.current = true; // boot resolved — activate URL syncing } })(); return () => { alive = false; }; }, []); // Keep the URL in sync with the current screen: a loaded game is // `#/game/`; everything else (home and the pick screens) is `#/`. This is // the single writer of the hash for app-driven transitions (game created, // resumed, or leaving for home). It only writes when the desired hash differs // from the current one, so it never triggers a redundant `hashchange`. useEffect(() => { if (!bootedRef.current) return; const desired = screen.name === 'game' && game ? `#/game/${game.id}` : '#/'; if (window.location.hash !== desired) window.location.hash = desired; }, [screen.name, game?.id]); // React to user-driven hash changes: manual edits and browser back/forward. // Only re-act when the parsed id actually differs from the loaded game (or the // hash went home while we're in a game), so the writes above never loop. Uses // `latestRef` so the mount-once effect always sees the current game + handlers. useEffect(() => { const onHashChange = () => { const cur = latestRef.current; if (!cur) return; const id = hashGameId(window.location.hash); if (id) { if (id === cur.game?.id) return; // already showing this game — no-op void cur.loadGame(id); } else if (cur.game) { cur.goHome(); // hash is home but we're in a game — return home } // else: hash is home and we're already on home/pick — nothing to do }; window.addEventListener('hashchange', onHashChange); return () => window.removeEventListener('hashchange', onHashChange); }, []); // Keep the choropleth in sync with whatever state is shown (territory changes). useEffect(() => { if (!game) return; api .map(game.id) .then((r) => setRegions(r.regions)) .catch(() => {}); }, [game]); // Resync from the persisted server state on connect and after every turn. useSse(game?.id ?? null, { onState: applyState, onTurn: applyState, }); const play = useCallback(async (presetId: string) => { setLoading(true); // Explicitly starting a fresh game: drop the remembered active game so a // refresh won't resurrect the previous one (`pickNation` sets the new id). clearActiveGameId(); setActiveGameId(null); try { const { preset } = await api.getPreset(presetId); setScreen({ name: 'pick', preset }); } catch (e) { toastError(e); setScreen({ name: 'home' }); } finally { setLoading(false); } }, []); const pickNation = useCallback( async (nationId: string) => { if (screen.name !== 'pick') return; setLoading(true); try { const { game } = await api.createGame(screen.preset.id, nationId); // Remember this as the active game so a refresh resumes it. persistActiveGameId(game.id); setActiveGameId(game.id); applyState(game); setAdvisors([]); setScreen({ name: 'game' }); } catch (e) { toastError(e); } finally { setLoading(false); } }, [screen], ); // Add an action to the pending round. Fast, non-LLM — no `busy`; the pending // list updates from the response and the SSE `state` event. Both are // version-gated via applyState, so the item can never be clobbered by a // stale snapshot (#10). const runAddAction = useCallback( async (text: string) => { if (!game) return; try { const { game: g } = await api.addAction(game.id, text); applyState(g); } catch (e) { toastError(e); } }, [game, applyState], ); // Add a diplomatic message to the pending round. Fast, non-LLM — no `busy`. const runAddDiplomacy = useCallback( async (to: string, message: string) => { if (!game) return; try { const { game: g } = await api.addDiplomacy(game.id, to, message); applyState(g); } catch (e) { toastError(e); } }, [game, applyState], ); // Remove a queued item by index. Fast, non-LLM — no `busy`; the pending list // updates from the response and the SSE `state` event (version-gated). const runRemoveQueueItem = useCallback( async (index: number) => { if (!game) return; try { const { game: g } = await api.removeQueueItem(game.id, index); applyState(g); } catch (e) { toastError(e); } }, [game, applyState], ); // Jump forward: the single LLM call that commits the pending round. Only this // sets `busy` (the adds above are fast and non-LLM). const runJump = useCallback(async () => { if (!game) return; setBusy(true); try { const { state } = await api.jump(game.id); applyState(state); } catch (e) { toastError(e); } finally { setBusy(false); } }, [game, applyState]); // Ask the advisor: a separate (slow) LLM call. It sets its own `advisorBusy` // flag (distinct from the turn `busy`) and clears it in `finally`, so the flag // is released on both success and error. Asking never advances a turn and // never touches the turn `busy` state — "Jump forward" stays usable. const askAdvisor = useCallback( async (prompt?: string) => { if (!game) return; setAdvisorBusy(true); try { const { suggestions } = await api.advisor(game.id, prompt); setAdvisors(suggestions); } catch (e) { toastError(e); } finally { setAdvisorBusy(false); } }, [game], ); // Load (fetch + show) a saved game by id. Shared by the home screen's resume // affordances and by the hash router when the URL points at a different game. // On failure (deleted / 404) it never throws: it clears the stale reference, // clears the hash to `#/`, and stays on the home screen. const loadGame = useCallback(async (id: string) => { setLoading(true); try { const { game } = await api.getGame(id); // Make this the active game so a bare `/` reload keeps resuming it. persistActiveGameId(id); setActiveGameId(id); applyState(game); setAdvisors([]); setScreen({ name: 'game' }); } catch (e) { clearActiveGameId(); setActiveGameId(null); setHomeHash(); setScreen({ name: 'home' }); toastError(e); } finally { setLoading(false); } }, []); // The home screen's "resume" affordances just load a saved game by id. const resumeGame = useCallback((id: string) => void loadGame(id), [loadGame]); const goHome = useCallback(() => { setScreen({ name: 'home' }); setGame(null); setRegions({}); setAdvisors([]); }, []); // Expose the latest game + handlers to the mount-once hashchange listener. latestRef.current = { game, goHome, loadGame }; if (screen.name === 'game' && game) { return ( <> ); } if (screen.name === 'pick') { return ( <> ); } return ( <> ); } interface GameScreenProps { game: GameState; regions: RegionMap; /** A turn ("Jump forward") is in flight — drives the turn flow + commit button. */ busy: boolean; /** An advisor fetch is in flight — drives the advisor UI only (never the turn). */ advisorBusy: boolean; advisors: string[]; onAddAction: (a: string) => void; onAddDiplomacy: (to: string, m: string) => void; onRemoveQueueItem: (index: number) => void; onAdvisor: (p?: string) => void; onJump: () => void; onHome: () => void; } /** * The main play view — a full-bleed world map with a floating HUD. The MapLibre * map is a fixed, full-viewport background (z-0) that the user drives directly * (wheel/trackpad zoom + drag pan — there is no floating zoom control). Over it: * a slim **pinned** header bar (fixed, non-movable, centered at the top a few * px down; scenario, turn + date, player nation, live status, "Focus map" * collapse-all); the nation dossier, the event feed, and the "Your move" * command deck — each free-floating by default (at the same first-run positions * as before) but **dockable** into a full-height left or right pane, the choice * being remembered; and the nation-color legend, which stays a small floating * widget (not dockable). Each floating window remembers its dragged position, * and clicking one raises it above its siblings. Pan/zoom/click on the map * stays fully available in the gaps between windows. */ function GameScreen({ game, regions, busy, advisorBusy, advisors, onAddAction, onAddDiplomacy, onRemoveQueueItem, onAdvisor, onJump, onHome, }: GameScreenProps) { // Which nation's dossier to show. Defaults to the player; remounts per game. const [viewingId, setViewingId] = useState(game.playerNationId); const player = game.nations[game.playerNationId]; const viewed = game.nations[viewingId] ?? player; const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name)); // Collapse state for the HUD panels, persisted per id (survives reloads). const [collapsed, setCollapsed] = useState>(loadCollapsedMap); const setCollapsedFor = useCallback((id: string, c: boolean) => { setCollapsed((prev) => { const next = { ...prev, [id]: c }; persistCollapsedMap(next); return next; }); }, []); const allCollapsed = PANEL_WINDOW_IDS.every((id) => collapsed[id]); // "Focus map" collapses every panel at once to reveal the bare map; the same // button (now "Show panels") restores them. const toggleAllPanels = useCallback(() => { setCollapsed((prev) => { const target = !PANEL_WINDOW_IDS.every((id) => prev[id]); const next = { ...prev }; for (const id of PANEL_WINDOW_IDS) next[id] = target; persistCollapsedMap(next); return next; }); }, []); // Dock state for the three dockable panels (dossier / feed / action), // persisted per id (survives reloads). The default is 'float' — the current // first-run positions — so nothing visually changes until the user docks // something. The legend is not dockable; the header is pinned. const [dock, setDock] = useState>(loadDockMap); const setDockFor = useCallback((id: string, mode: DockMode) => { setDock((prev) => { const next = { ...prev, [id]: mode }; persistDockMap(next); return next; }); }, []); // The `dock` prop expects a concrete edge, or `undefined` to free-float. const dockOf = (id: string) => { const m = dock[id]; return m === 'left' || m === 'right' ? m : undefined; }; return (
{/* Full-viewport background map, behind every floating window. */} {/* Pinned top bar: brand, turn + date, player nation, live status, Focus map. Fixed and non-movable, centered at the top a few px from the top edge. */}
History
{game.presetTitle}
} titleExtra={
Turn
{game.turn}
Date
{game.date}
{player?.name ?? game.playerNationId}
{/* Three-state live status: advisor fetch in flight → "Advisor thinking…"; else a turn in flight → "Simulating…"; else "Idle". Both LLM-in-flight states share the amber spinner; idle is muted. */} {advisorBusy || busy ? ( ) : ( )} {advisorBusy ? 'Advisor thinking…' : busy ? 'Simulating…' : 'Idle'}
} /> {/* Nation dossier, top-left by default; dockable into a left/right pane. */} } dock={dockOf('dossier')} onDockChange={(m) => setDockFor('dossier', m)} collapsed={!!collapsed.dossier} onCollapsedChange={(c) => setCollapsedFor('dossier', c)} bodyClassName="max-h-[56vh] overflow-y-auto" >
Inspect
{/* Event feed, right side by default; dockable into a left/right pane. */} } titleExtra={ {game.events.length} } dock={dockOf('feed')} onDockChange={(m) => setDockFor('feed', m)} collapsed={!!collapsed.feed} onCollapsedChange={(c) => setCollapsedFor('feed', c)} bodyClassName="h-[52vh]" > {/* Command deck ("Your move"), bottom-center by default; dockable. */} } titleExtra={ game.pending.length > 0 ? ( {game.pending.length} queued ) : undefined } dock={dockOf('action')} onDockChange={(m) => setDockFor('action', m)} collapsed={!!collapsed.action} onCollapsedChange={(c) => setCollapsedFor('action', c)} bodyClassName="max-h-[72vh] overflow-y-auto" > {/* Nation-color legend, bottom-left. */} } collapsed={!!collapsed.legend} onCollapsedChange={(c) => setCollapsedFor('legend', c)} >
); }