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
+157
View File
@@ -0,0 +1,157 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import type { GameState } from '@shared/types';
interface Props {
game: GameState;
/** A turn is currently resolving (action/chat/jump) — disable those controls. */
busy: boolean;
/** Advisor brainstorm suggestions from the last request. */
advisors: string[];
onAction: (action: string) => void;
onChat: (to: string, message: string) => void;
onAdvisor: (prompt?: string) => void;
onJump: () => void;
}
/**
* The player's command deck. Three tabs:
* - Action: free-text move (the primary way to shape the world).
* - Diplomacy: a message to a specific other nation.
* - Advisor: ask the LLM for 3–5 in-character action ideas; "Use" feeds one
* straight into the Action tab.
* A prominent "Jump forward" button auto-simulates the world with no player move.
*/
export default function ActionPanel({ game, busy, advisors, onAction, onChat, onAdvisor, onJump }: Props) {
const [tab, setTab] = useState('action');
const [action, setAction] = useState('');
const [target, setTarget] = useState('');
const [message, setMessage] = useState('');
const [prompt, setPrompt] = useState('');
const others = Object.entries(game.nations)
.filter(([id]) => id !== game.playerNationId)
.sort(([a], [b]) => a.localeCompare(b));
const submitAction = () => {
const text = action.trim();
if (!text || busy) return;
setAction('');
onAction(text);
};
const submitChat = () => {
const msg = message.trim();
if (!msg || !target || busy) return;
setMessage('');
onChat(target, msg);
};
const applyIdea = (idea: string) => {
setAction(idea);
setTab('action');
};
return (
<Card className="gap-4" data-testid="action-panel">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Command</CardTitle>
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
{busy ? 'Simulating…' : 'Jump forward'}
</Button>
</CardHeader>
<CardContent>
<Tabs value={tab} onValueChange={setTab} className="gap-3">
<TabsList className="grid w-full grid-cols-3" data-testid="action-tabs">
<TabsTrigger value="action" data-testid="tab-action">
Action
</TabsTrigger>
<TabsTrigger value="diplo" data-testid="tab-diplo">
Diplomacy
</TabsTrigger>
<TabsTrigger value="advisor" data-testid="tab-advisor">
Advisor
</TabsTrigger>
</TabsList>
<TabsContent value="action" className="space-y-2">
<Textarea
value={action}
onChange={(e) => setAction(e.target.value)}
placeholder="Describe your move — e.g. “I mobilize reserves and fortify the western border.”"
rows={3}
data-testid="action-textarea"
/>
<Button onClick={submitAction} disabled={busy || !action.trim()} className="w-full" data-testid="action-submit">
Take action
</Button>
</TabsContent>
<TabsContent value="diplo" className="space-y-2">
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="w-full" data-testid="chat-target">
<SelectValue placeholder="Choose a nation to address…" />
</SelectTrigger>
<SelectContent>
{others.map(([id, n]) => (
<SelectItem key={id} value={id}>
{n.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Your diplomatic message…"
rows={3}
data-testid="chat-message"
/>
<Button onClick={submitChat} disabled={busy || !message.trim() || !target} className="w-full" data-testid="chat-submit">
Send message
</Button>
</TabsContent>
<TabsContent value="advisor" className="space-y-2">
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Optional focus — e.g. “the nuclear standoff”"
data-testid="advisor-prompt"
/>
<Button
variant="secondary"
onClick={() => onAdvisor(prompt.trim() || undefined)}
className="w-full"
data-testid="advisor-ask"
>
Ask the advisor
</Button>
{advisors.length > 0 && (
<ul className="space-y-1.5" data-testid="advisor-list">
{advisors.map((a, i) => (
<li
key={i}
className="flex items-start gap-2 rounded-md border bg-card/50 p-2 text-sm"
data-testid="advisor-item"
>
<span className="flex-1 leading-snug">{a}</span>
<Button size="sm" variant="outline" onClick={() => applyIdea(a)}>
Use
</Button>
</li>
))}
</ul>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}