History: AI-driven alternate-history simulator
Pax Historia-style sim where the LLM is the world engine.
- React 19 + Vite 6 + Tailwind v4 + shadcn/ui frontend
- Express single-port backend (/api + /sse + built client + SPA fallback)
- SQLite (better-sqlite3) persistence, one row per game
- MapLibre choropleth map (OpenFreeMap dark + ISO_A3 regions)
- Defensive turn loop: strip fences, balanced-{} extract, parse, clamp 0-100,
retry-once nudge, generic 'unclear' fallback; never crashes on bad output
- 5 seed presets (wwii-1939, coldwar-1955, modern-2025, no-wwii-1940, aegis-2400)
- LLM client over native fetch w/ injectable transport (mesh-bot pattern);
LLM_STUB=1 deterministic transport for tests/e2e
- Unit (vitest, 19) + deterministic Playwright e2e
- Multi-stage node:22-slim Docker + Traefik (history.dev.kkhost.pl:4321)
Co-Authored-By: OpenCode <opencode@users.noreply.github.com>
This commit is contained in:
+293
@@ -0,0 +1,293 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
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 PresetPicker from './components/PresetPicker';
|
||||
import NationSelect from './components/NationSelect';
|
||||
import WorldMap from './components/WorldMap';
|
||||
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 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' };
|
||||
|
||||
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);
|
||||
|
||||
// Load the scenario manifest once, on the home screen.
|
||||
useEffect(() => {
|
||||
if (screen.name !== 'home') return;
|
||||
let alive = true;
|
||||
api
|
||||
.listPresets()
|
||||
.then((r) => alive && setPresets(r.presets))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [screen.name]);
|
||||
|
||||
// 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);
|
||||
try {
|
||||
const { preset } = await api.getPreset(presetId);
|
||||
setScreen({ name: 'pick', preset });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load scenario');
|
||||
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);
|
||||
setGame(game);
|
||||
setAdvisors([]);
|
||||
setScreen({ name: 'game' });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not start the game');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[screen],
|
||||
);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (action: string) => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.turn(game.id, action);
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Action failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[game],
|
||||
);
|
||||
|
||||
const runChat = useCallback(
|
||||
async (to: string, message: string) => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.turn(game.id, undefined, { to, message });
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Diplomacy failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[game],
|
||||
);
|
||||
|
||||
const runJump = useCallback(async () => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.jump(game.id);
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Jump failed');
|
||||
} 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) {
|
||||
toast.error(e instanceof Error ? e.message : 'Advisor failed');
|
||||
}
|
||||
},
|
||||
[game],
|
||||
);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
setScreen({ name: 'home' });
|
||||
setGame(null);
|
||||
setRegions({});
|
||||
setAdvisors([]);
|
||||
}, []);
|
||||
|
||||
if (screen.name === 'game' && game) {
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<GameScreen
|
||||
key={game.id}
|
||||
game={game}
|
||||
regions={regions}
|
||||
busy={busy}
|
||||
advisors={advisors}
|
||||
onAction={runAction}
|
||||
onChat={runChat}
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface GameScreenProps {
|
||||
game: GameState;
|
||||
regions: RegionMap;
|
||||
busy: boolean;
|
||||
advisors: string[];
|
||||
onAction: (a: string) => void;
|
||||
onChat: (to: string, m: string) => void;
|
||||
onAdvisor: (p?: string) => void;
|
||||
onJump: () => void;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
/** The main play view: map + nation dossier on the left, command deck + event feed on the right. */
|
||||
function GameScreen({
|
||||
game,
|
||||
regions,
|
||||
busy,
|
||||
advisors,
|
||||
onAction,
|
||||
onChat,
|
||||
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));
|
||||
|
||||
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">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<Select value={viewingId} onValueChange={setViewingId}>
|
||||
<SelectTrigger className="w-56" 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} />
|
||||
</section>
|
||||
|
||||
<aside className="flex min-w-0 flex-col gap-4">
|
||||
<ActionPanel
|
||||
game={game}
|
||||
busy={busy}
|
||||
advisors={advisors}
|
||||
onAction={onAction}
|
||||
onChat={onChat}
|
||||
onAdvisor={onAdvisor}
|
||||
onJump={onJump}
|
||||
/>
|
||||
<div className="h-[46vh] min-h-[280px]" data-testid="event-feed-slot">
|
||||
<EventFeed events={game.events} nations={game.nations} />
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user