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
+91
View File
@@ -0,0 +1,91 @@
import { Badge } from './ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import StatBar from './StatBar';
import { nationColor } from '@shared/protocol';
import { RELATION_COLORS } from '@/lib/colors';
import type { Nation } from '@shared/types';
interface Props {
nation: Nation;
nations: Record<string, Nation>;
/** True when this is the nation the player is leading. */
isPlayer: boolean;
}
/**
* A single nation's dossier: identity, the four 0–100 stat bars (colored by the
* nation's stable choropleth color), territory count, and its diplomatic posture
* toward every other nation. Used for the player (highlighted) or any inspected
* nation.
*/
export default function NationPanel({ nation, nations, isPlayer }: Props) {
const color = nationColor(nation.id);
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
const regions = nation.territory;
return (
<Card data-testid="nation-panel" className={isPlayer ? 'gap-4 border-primary/40' : 'gap-4'}>
<CardHeader className="flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2.5">
<span className="inline-block size-3.5 rounded-full" style={{ background: color }} />
<div className="space-y-0.5">
<CardTitle className="text-lg">{nation.name}</CardTitle>
<CardDescription>
{nation.type} · {regions.length} region{regions.length === 1 ? '' : 's'}
</CardDescription>
</div>
</div>
<Badge variant={isPlayer ? 'default' : 'secondary'} className="shrink-0">
{isPlayer ? 'Your nation' : 'Other'}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
<StatBar label="Military" value={nation.stats.military} color={color} testId={isPlayer ? 'stat-military' : undefined} />
<StatBar label="Economy" value={nation.stats.economy} color={color} testId={isPlayer ? 'stat-economy' : undefined} />
<StatBar label="Stability" value={nation.stats.stability} color={color} testId={isPlayer ? 'stat-stability' : undefined} />
<StatBar label="Population" value={nation.stats.population} color={color} testId={isPlayer ? 'stat-population' : undefined} />
</div>
{regions.length > 0 && (
<div>
<h3 className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Territory</h3>
<div className="flex flex-wrap gap-1.5">
{regions.map((code) => (
<span
key={code}
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
style={{ borderColor: `${color}66`, color }}
>
{code}
</span>
))}
</div>
</div>
)}
{relations.length > 0 && (
<div>
<h3 className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Diplomacy</h3>
<div className="flex flex-wrap gap-1.5">
{relations.map(([id, status]) => {
const other = nations[id];
const c = RELATION_COLORS[status] ?? '#94a3b8';
return (
<span
key={id}
className="rounded-md border px-2 py-0.5 text-xs"
style={{ borderColor: `${c}66`, color: c }}
>
{other?.name ?? id}: {status}
</span>
);
})}
</div>
</div>
)}
</CardContent>
</Card>
);
}