808 lines
29 KiB
TypeScript
808 lines
29 KiB
TypeScript
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<string, boolean> {
|
|
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<string, boolean> = {};
|
|
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
|
if (typeof val === 'boolean') out[k] = val;
|
|
}
|
|
return out;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function persistCollapsedMap(m: Record<string, boolean>): 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<string, DockMode> {
|
|
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<string, DockMode> = {};
|
|
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
|
if (val === 'left' || val === 'right' || val === 'float') out[k] = val;
|
|
}
|
|
return out;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function persistDockMap(m: Record<string, DockMode>): 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/<id>`.
|
|
// 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/<id>`, 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<Screen>({ name: 'home' });
|
|
const [presets, setPresets] = useState<PresetMeta[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [game, setGame] = useState<GameState | null>(null);
|
|
const [regions, setRegions] = useState<RegionMap>({});
|
|
const [advisors, setAdvisors] = useState<string[]>([]);
|
|
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<string | null>(() => readActiveGameId());
|
|
const [savedGames, setSavedGames] = useState<GameSummary[]>([]);
|
|
// 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/<id>`) 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/<id>`; 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 (
|
|
<>
|
|
<Toaster richColors position="top-right" />
|
|
<GameScreen
|
|
key={game.id}
|
|
game={game}
|
|
regions={regions}
|
|
busy={busy}
|
|
advisorBusy={advisorBusy}
|
|
advisors={advisors}
|
|
onAddAction={runAddAction}
|
|
onAddDiplomacy={runAddDiplomacy}
|
|
onRemoveQueueItem={runRemoveQueueItem}
|
|
onAdvisor={askAdvisor}
|
|
onJump={runJump}
|
|
onHome={goHome}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (screen.name === 'pick') {
|
|
return (
|
|
<>
|
|
<Toaster richColors position="top-right" />
|
|
<NationSelect preset={screen.preset} loading={loading} onPick={pickNation} onBack={goHome} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Toaster richColors position="top-right" />
|
|
<PresetPicker
|
|
presets={presets}
|
|
loading={loading}
|
|
onPlay={play}
|
|
savedGames={savedGames}
|
|
activeGameId={activeGameId}
|
|
onResume={resumeGame}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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<Record<string, boolean>>(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<Record<string, DockMode>>(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 (
|
|
<div className="min-h-screen">
|
|
{/* Full-viewport background map, behind every floating window. */}
|
|
<WorldMap
|
|
regions={regions}
|
|
playerNationId={game.playerNationId}
|
|
nations={game.nations}
|
|
presetId={game.presetId}
|
|
onFocusNation={setViewingId}
|
|
/>
|
|
|
|
{/* 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. */}
|
|
<FloatingWindow
|
|
id="header"
|
|
dataTestId="top-bar"
|
|
pinned
|
|
collapsible={false}
|
|
title={
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Button variant="ghost" size="sm" onClick={onHome} className="shrink-0" data-testid="back-to-scenarios">
|
|
← Scenarios
|
|
</Button>
|
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
<Radar className="size-5 shrink-0 text-primary" />
|
|
<div className="min-w-0 leading-tight">
|
|
<div className="truncate text-base font-bold tracking-tight">History</div>
|
|
<div className="truncate text-[11px] font-normal text-muted-foreground">
|
|
{game.presetTitle}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
titleExtra={
|
|
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1">
|
|
<div className="text-right leading-none">
|
|
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
|
|
Turn
|
|
</div>
|
|
<div className="mt-0.5 text-xl font-bold tabular-nums" data-testid="turn-count">
|
|
{game.turn}
|
|
</div>
|
|
</div>
|
|
<div className="hidden h-8 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-8 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>
|
|
{/* 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. */}
|
|
<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',
|
|
advisorBusy || busy
|
|
? 'border-primary/40 bg-primary/10 text-primary'
|
|
: 'border-border bg-muted/40 text-muted-foreground',
|
|
)}
|
|
>
|
|
{advisorBusy || busy ? (
|
|
<Loader2 className="size-3 animate-spin" />
|
|
) : (
|
|
<CircleDot className="size-3" />
|
|
)}
|
|
{advisorBusy ? 'Advisor thinking…' : busy ? 'Simulating…' : 'Idle'}
|
|
</span>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={toggleAllPanels}
|
|
data-testid="focus-map"
|
|
title={allCollapsed ? 'Show all panels' : 'Collapse all panels to reveal the map'}
|
|
>
|
|
<MapIcon className="size-3.5" />
|
|
{allCollapsed ? 'Show panels' : 'Focus map'}
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{/* Nation dossier, top-left by default; dockable into a left/right pane. */}
|
|
<FloatingWindow
|
|
id="dossier"
|
|
dataTestId="panel-dossier"
|
|
anchor="top-left"
|
|
width={300}
|
|
title="Dossier"
|
|
titleIcon={<BookOpen className="size-4" />}
|
|
dock={dockOf('dossier')}
|
|
onDockChange={(m) => setDockFor('dossier', m)}
|
|
collapsed={!!collapsed.dossier}
|
|
onCollapsedChange={(c) => setCollapsedFor('dossier', c)}
|
|
bodyClassName="max-h-[56vh] overflow-y-auto"
|
|
>
|
|
<div className="border-b border-border/60 p-3">
|
|
<div className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
Inspect
|
|
</div>
|
|
<Select value={viewingId} onValueChange={setViewingId}>
|
|
<SelectTrigger className="w-full" data-testid="inspect-select">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{nations.map((n) => (
|
|
<SelectItem key={n.id} value={n.id}>
|
|
{n.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
|
|
</FloatingWindow>
|
|
|
|
{/* Event feed, right side by default; dockable into a left/right pane. */}
|
|
<FloatingWindow
|
|
id="feed"
|
|
dataTestId="panel-feed"
|
|
anchor="right"
|
|
width={330}
|
|
title="Event feed"
|
|
titleIcon={<Activity className="size-4" />}
|
|
titleExtra={
|
|
<Badge variant="secondary" className="shrink-0 text-xs tabular-nums">
|
|
{game.events.length}
|
|
</Badge>
|
|
}
|
|
dock={dockOf('feed')}
|
|
onDockChange={(m) => setDockFor('feed', m)}
|
|
collapsed={!!collapsed.feed}
|
|
onCollapsedChange={(c) => setCollapsedFor('feed', c)}
|
|
bodyClassName="h-[52vh]"
|
|
>
|
|
<EventFeed events={game.events} nations={game.nations} inWindow />
|
|
</FloatingWindow>
|
|
|
|
{/* Command deck ("Your move"), bottom-center by default; dockable. */}
|
|
<FloatingWindow
|
|
id="action"
|
|
dataTestId="panel-action"
|
|
anchor="bottom-center"
|
|
width={370}
|
|
title="Your move"
|
|
titleIcon={<Radar className="size-4 text-primary" />}
|
|
titleExtra={
|
|
game.pending.length > 0 ? (
|
|
<Badge variant="secondary" className="shrink-0 text-xs tabular-nums" data-testid="title-queued">
|
|
{game.pending.length} queued
|
|
</Badge>
|
|
) : undefined
|
|
}
|
|
dock={dockOf('action')}
|
|
onDockChange={(m) => setDockFor('action', m)}
|
|
collapsed={!!collapsed.action}
|
|
onCollapsedChange={(c) => setCollapsedFor('action', c)}
|
|
bodyClassName="max-h-[72vh] overflow-y-auto"
|
|
>
|
|
<ActionPanel
|
|
game={game}
|
|
busy={busy}
|
|
advisorBusy={advisorBusy}
|
|
advisors={advisors}
|
|
onAddAction={onAddAction}
|
|
onAddDiplomacy={onAddDiplomacy}
|
|
onRemoveQueueItem={onRemoveQueueItem}
|
|
onAdvisor={onAdvisor}
|
|
onJump={onJump}
|
|
inWindow
|
|
/>
|
|
</FloatingWindow>
|
|
|
|
{/* Nation-color legend, bottom-left. */}
|
|
<FloatingWindow
|
|
id="legend"
|
|
anchor="bottom-left"
|
|
width={170}
|
|
title="Legend"
|
|
titleIcon={<ListTree className="size-4" />}
|
|
collapsed={!!collapsed.legend}
|
|
onCollapsedChange={(c) => setCollapsedFor('legend', c)}
|
|
>
|
|
<MapLegend regions={regions} />
|
|
</FloatingWindow>
|
|
</div>
|
|
);
|
|
}
|