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
+71
View File
@@ -0,0 +1,71 @@
import { Badge } from './ui/badge';
import { ScrollArea } from './ui/scroll-area';
import { EVENT_COLORS, OUTCOME_COLORS } from '@/lib/colors';
import type { EventEntry, Nation } from '@shared/types';
interface Props {
events: EventEntry[];
nations: Record<string, Nation>;
}
/**
* Chronological event log (newest first). Each entry shows the turn it resolved,
* its date, a color-coded type, a headline, the narrative, the nation it concerns,
* and the outcome. Scrolls internally; the parent supplies a bounded height.
*/
export default function EventFeed({ events, nations }: Props) {
const latestFirst = [...events].reverse();
return (
<div className="flex h-full min-h-0 flex-col rounded-xl border bg-card" data-testid="event-feed-panel">
<div className="flex items-center justify-between px-4 pt-4">
<h2 className="text-sm font-semibold">Event feed</h2>
<Badge variant="secondary" className="text-xs tabular-nums">
{events.length}
</Badge>
</div>
<ScrollArea className="mx-4 mb-4 min-h-0 flex-1">
<div className="space-y-2 pr-1" data-testid="event-feed">
{latestFirst.length === 0 && (
<p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
No events yet. Take an action, send a message, or jump forward to move the world.
</p>
)}
{latestFirst.map((e) => {
const nation = nations[e.nation];
const color = EVENT_COLORS[e.type] ?? '#94a3b8';
const outcome = OUTCOME_COLORS[e.outcome] ?? '#94a3b8';
return (
<article key={e.id} className="rounded-lg border bg-card/50 p-2.5" data-testid="event-item">
<div className="mb-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="font-medium tabular-nums">T{e.turn}</span>
<span>·</span>
<span className="tabular-nums">{e.date}</span>
<span className="ml-auto inline-flex items-center gap-1.5">
<span className="inline-block size-2 rounded-full" style={{ background: color }} />
{e.type}
</span>
</div>
<p className="text-sm font-medium leading-snug" style={{ color }}>
{e.title}
</p>
<p className="mt-0.5 text-sm leading-snug text-foreground/80">{e.text}</p>
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{nation?.name ?? e.nation}</span>
<span
className="ml-auto rounded px-1.5 py-0.5 font-medium capitalize"
style={{ background: `${outcome}22`, color: outcome }}
>
{e.outcome}
</span>
</div>
</article>
);
})}
</div>
</ScrollArea>
</div>
);
}