diff --git a/server/index.ts b/server/index.ts index 211c8ce..9dfdd56 100644 --- a/server/index.ts +++ b/server/index.ts @@ -40,7 +40,9 @@ const presetsDir = path.join(root, 'presets'); const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist'); const dataDir = process.env.DATA_DIR ?? path.join(root, 'data'); -const PORT = parseInt(process.env.PORT ?? '4321', 10); +// PORT wins; HISTORY_API_PORT is the override used by the dev proxy (vite) and +// the e2e config, so honor it as a fallback to keep all three in agreement. +const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10); const presetStore = createPresetStore(presetsDir); const db: Db = openDb(path.join(dataDir, 'history.db')); // LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model). diff --git a/src/App.tsx b/src/App.tsx index 7787177..c07b0ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,26 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; -import { CircleDot, Loader2, Radar } from 'lucide-react'; +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 from './components/WorldMap'; +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'; @@ -32,6 +45,39 @@ function toastError(e: unknown): void { } } +// --------------------------------------------------------------------------- +// 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 */ + } +} + export default function App() { const [screen, setScreen] = useState({ name: 'home' }); const [presets, setPresets] = useState([]); @@ -216,11 +262,15 @@ interface GameScreenProps { } /** - * 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. + * 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, @@ -239,115 +289,252 @@ function GameScreen({ 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 ( -
-
- -
- -
-

History

-

{game.presetTitle}

-
-
+
+ {/* Full-viewport background map, behind every floating window. */} + -
-
-
Turn
-
- {game.turn} + {/* Slim floating header: brand, turn + date, player nation, live status, Focus map. */} + + +
+ +
+
History
+
+ {game.presetTitle} +
+
-
-
-
Date
-
- {game.date} + } + titleExtra={ +
+
+
+ Turn +
+
+ {game.turn} +
+
+
+
+
+ Date +
+
+ {game.date} +
+
+
+
+ + + {player?.name ?? game.playerNationId} +
-
-
-
- - {player?.name ?? game.playerNationId} + 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 ? : } + {busy ? 'Simulating…' : 'Idle'} +
- - {busy ? ( - - ) : ( - - )} - {busy ? 'Simulating…' : 'Idle'} - + } + /> + + {/* Nation dossier, top-left. */} + } + collapsed={!!collapsed.dossier} + onCollapsedChange={(c) => setCollapsedFor('dossier', c)} + bodyClassName="max-h-[56vh] overflow-y-auto" + > +
+
+ Inspect +
+
-
+ + -
- {/* Left: nation dossier (mobile order 3). */} -
-
- Inspect - -
- -
+ {/* Event feed, right side. */} + } + titleExtra={ + + {game.events.length} + + } + collapsed={!!collapsed.feed} + onCollapsedChange={(c) => setCollapsedFor('feed', c)} + bodyClassName="h-[52vh]" + > + + - {/* Center: the dominant map (mobile order 1). */} -
-
- -
-
+ {/* Command deck ("Your move"), bottom-center — the prominent typing spot. */} + } + titleExtra={ + + } + collapsed={!!collapsed.action} + onCollapsedChange={(c) => setCollapsedFor('action', c)} + bodyClassName="max-h-[72vh] overflow-y-auto" + > + + - {/* Right: command deck + event feed (mobile order 2). */} - -
+ {/* 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)} + > +
+ + +
+
); } diff --git a/src/components/ActionPanel.tsx b/src/components/ActionPanel.tsx index fb9bc74..175519e 100644 --- a/src/components/ActionPanel.tsx +++ b/src/components/ActionPanel.tsx @@ -18,6 +18,12 @@ interface Props { onChat: (to: string, message: string) => void; onAdvisor: (prompt?: string) => void; onJump: () => void; + /** + * Render without the outer card chrome (the surrounding floating window + * provides the border, the "Your move" title, and the "Jump forward" button + * in its title bar). Same content, same testids. + */ + inWindow?: boolean; } /** @@ -28,7 +34,16 @@ interface Props { * that turns take a few seconds. A "Jump forward" button auto-simulates the * world with no player move. */ -export default function ActionPanel({ game, busy, advisors, onAction, onChat, onAdvisor, onJump }: Props) { +export default function ActionPanel({ + game, + busy, + advisors, + onAction, + onChat, + onAdvisor, + onJump, + inWindow = false, +}: Props) { const [tab, setTab] = useState('action'); const [action, setAction] = useState(''); const [target, setTarget] = useState(''); @@ -82,110 +97,127 @@ export default function ActionPanel({ game, busy, advisors, onAction, onChat, on else onAdvisor(prompt.trim() || undefined); }; + // In window mode the surrounding FloatingWindow shows "Your move" and the + // "Jump forward" button in its own title bar, so omit both here. + const header = inWindow ? null : ( + +
+ + Your move +
+ +
+ ); + + const body = ( + <> + + + + Action + + + Diplomacy + + + Advisor + + + + +