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:
2026-10-10 21:20:23 +00:00
co-authored by OpenCode
commit e2936e85bb
65 changed files with 14257 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import type { PresetMeta } from '@shared/types';
interface Props {
presets: PresetMeta[];
loading?: boolean;
onPlay: (presetId: string) => void;
}
/** Home screen: a grid of scenario cards, each with a Play button. */
export default function PresetPicker({ presets, loading, onPlay }: Props) {
return (
<div className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">History</h1>
<p className="mt-2 max-w-2xl text-muted-foreground">
An AI-driven alternate-history simulator. Pick a scenario and a nation, then shape the
world turn by turn — the LLM is the world engine.
</p>
</header>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{presets.map((p) => (
<Card
key={p.id}
className="flex flex-col transition hover:ring-1 hover:ring-primary/40"
data-testid="preset-card"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-lg">{p.title}</CardTitle>
<Badge variant="secondary" className="shrink-0 text-xs">
{p.startDate}
</Badge>
</div>
<CardDescription className="text-sm leading-relaxed">{p.summary}</CardDescription>
</CardHeader>
<CardContent className="mt-auto">
<Button className="w-full" onClick={() => onPlay(p.id)} disabled={loading}>
Play
</Button>
</CardContent>
</Card>
))}
</div>
</div>
);
}