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