import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { Activity, BookOpen, CircleDot, ListTree, Loader2, Map as MapIcon, Minus, Plus, 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, { type MapApi } 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 persistence (positions live in FloatingWindow itself). // --------------------------------------------------------------------------- const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed'; /** Every collapsible HUD panel (the slim header is never collapsed). */ const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend', 'zoom'] 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 */ } } // --------------------------------------------------------------------------- // 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); // 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); // 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); setGame(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. // Handlers only call the stable setGame, so no stale-closure concerns. useSse(game?.id ?? null, { onState: (s) => setGame(s), onTurn: (s) => setGame(s), }); 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); setGame(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. const runAddAction = useCallback( async (text: string) => { if (!game) return; try { const { game: g } = await api.addAction(game.id, text); setGame(g); } catch (e) { toastError(e); } }, [game], ); // 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); setGame(g); } catch (e) { toastError(e); } }, [game], ); // 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); setGame(state); } catch (e) { toastError(e); } finally { setBusy(false); } }, [game]); const askAdvisor = useCallback( async (prompt?: string) => { if (!game) return; try { const { suggestions } = await api.advisor(game.id, prompt); setAdvisors(suggestions); } catch (e) { toastError(e); } }, [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); setGame(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; busy: boolean; advisors: string[]; onAddAction: (a: string) => void; onAddDiplomacy: (to: string, m: string) => 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); over it float draggable, * collapsible windows: a slim header bar (scenario, turn + date, player nation, * live status, "Focus map" collapse-all) at the top, the nation dossier * (top-left), the event feed (right), the "Your move" command deck * (bottom-center), the nation-color legend (bottom-left), and a tiny map zoom * control (bottom-right). Each 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, advisors, onAddAction, onAddDiplomacy, 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; }); }, []); // Tiny map API (zoom in/out) exposed by WorldMap for the floating control. const mapApiRef = useRef(null); const handleMapReady = useCallback((api: MapApi | null) => { mapApiRef.current = api; }, []); return (
{/* Full-viewport background map, behind every floating window. */} {/* Slim floating header: brand, turn + date, player nation, live status, Focus map. */}
History
{game.presetTitle}
} titleExtra={
Turn
{game.turn}
Date
{game.date}
{player?.name ?? game.playerNationId}
{busy ? : } {busy ? 'Simulating…' : 'Idle'}
} /> {/* Nation dossier, top-left. */} } collapsed={!!collapsed.dossier} onCollapsedChange={(c) => setCollapsedFor('dossier', c)} bodyClassName="max-h-[56vh] overflow-y-auto" >
Inspect
{/* Event feed, right side. */} } titleExtra={ {game.events.length} } collapsed={!!collapsed.feed} onCollapsedChange={(c) => setCollapsedFor('feed', c)} bodyClassName="h-[52vh]" > {/* Command deck ("Your move"), bottom-center — queue items, then commit. */} } titleExtra={ game.pending.length > 0 ? ( {game.pending.length} queued ) : undefined } 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)} > {/* Tiny floating zoom control, bottom-right (clear of the attribution). */} } offset={{ y: -28 }} collapsed={!!collapsed.zoom} onCollapsedChange={(c) => setCollapsedFor('zoom', c)} >
); }