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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user