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,168 @@
|
||||
// Client <-> server protocol types + a few shared pure helpers (single source of
|
||||
// truth). The LLM is asked to return exactly the `TurnResult` shape; the engine
|
||||
// validates/clamps it before applying it to a GameState.
|
||||
import type {
|
||||
EventEntry,
|
||||
EventOutcome,
|
||||
EventType,
|
||||
GameState,
|
||||
Nation,
|
||||
RelationStatus,
|
||||
StatKey,
|
||||
} from './types';
|
||||
|
||||
/** A numeric change to one of a nation's 0–100 stats. */
|
||||
export type StateChange =
|
||||
| { nation: string; stat: StatKey; op: 'set'; value: number }
|
||||
| { nation: string; stat: StatKey; op: 'add'; value: number };
|
||||
|
||||
/** Set the bilateral relation between nations `a` and `b` to `set` (symmetric). */
|
||||
export interface RelationChange {
|
||||
a: string;
|
||||
b: string;
|
||||
set: RelationStatus;
|
||||
}
|
||||
|
||||
/** A nation gains/loses ISO_A3 regions this turn. */
|
||||
export interface TerritoryChange {
|
||||
nation: string;
|
||||
gain: string[];
|
||||
lose: string[];
|
||||
}
|
||||
|
||||
/** One narrative event the LLM proposes for this turn. */
|
||||
export interface TurnEventSpec {
|
||||
nation: string;
|
||||
type: EventType;
|
||||
title: string;
|
||||
text: string;
|
||||
outcome: EventOutcome;
|
||||
}
|
||||
|
||||
/** The exact JSON object the world-simulator LLM must return for a turn. */
|
||||
export interface TurnResult {
|
||||
date: string;
|
||||
events: TurnEventSpec[];
|
||||
stateChanges: StateChange[];
|
||||
relations: RelationChange[];
|
||||
territoryChanges: TerritoryChange[];
|
||||
}
|
||||
|
||||
/** Per-region map payload: ISO_A3 -> owning nation + its stable color. */
|
||||
export interface RegionOwner {
|
||||
color: string;
|
||||
owner: string;
|
||||
ownerId: string;
|
||||
}
|
||||
export type RegionMap = Record<string, RegionOwner>;
|
||||
|
||||
/** Server -> client Server-Sent Events payload (see server/index.ts /sse). */
|
||||
export type SseMessage =
|
||||
| { type: 'state'; state: GameState }
|
||||
| { type: 'turn'; state: GameState; result: TurnResult }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
// --- API request / response shapes (REST) ---
|
||||
|
||||
export interface CreateGameRequest {
|
||||
presetId: string;
|
||||
playerNationId: string;
|
||||
}
|
||||
export interface CreateGameResponse {
|
||||
game: GameState;
|
||||
}
|
||||
|
||||
export interface TurnRequest {
|
||||
/** Free-text player action. Omit for a "jump forward" auto turn. */
|
||||
action?: string;
|
||||
/** Optional diplomatic message to a target nation. */
|
||||
chat?: { to: string; message: string };
|
||||
}
|
||||
export interface TurnResponse {
|
||||
state: GameState;
|
||||
result: TurnResult;
|
||||
}
|
||||
|
||||
export interface AdvisorRequest {
|
||||
/** Optional prompt to steer the brainstorm. */
|
||||
prompt?: string;
|
||||
}
|
||||
export interface AdvisorResponse {
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export interface GameSummary {
|
||||
id: string;
|
||||
presetId: string;
|
||||
presetTitle: string;
|
||||
playerNationId: string;
|
||||
turn: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic, stable per-nation color for the choropleth. Hashes the nation id
|
||||
* to a hue; sat/light tuned for the dark command-center theme. Pure + shared so
|
||||
* the server's RegionMap and any client-side coloring always agree.
|
||||
*/
|
||||
export function nationColor(id: string): string {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
h = Math.imul(h ^ id.charCodeAt(i), 16777619) >>> 0;
|
||||
}
|
||||
const hue = h % 360;
|
||||
return `hsl(${hue} 58% 46%)`;
|
||||
}
|
||||
|
||||
/** Rebuild the full event list entry shape from a TurnEventSpec + turn/date. */
|
||||
export function makeEvent(
|
||||
spec: TurnEventSpec,
|
||||
turn: number,
|
||||
date: string,
|
||||
): EventEntry {
|
||||
return {
|
||||
id: `${turn}-${spec.nation}-${spec.type}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
turn,
|
||||
date,
|
||||
nation: spec.nation,
|
||||
type: spec.type,
|
||||
title: spec.title,
|
||||
text: spec.text,
|
||||
outcome: spec.outcome,
|
||||
};
|
||||
}
|
||||
|
||||
/** Defensive helpers used by both the engine (server) and tests. */
|
||||
export function clampStat(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(v)));
|
||||
}
|
||||
|
||||
/** True if the string looks like a `YYYY-MM-DD` date. */
|
||||
export function isDateLike(s: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s));
|
||||
}
|
||||
|
||||
/** Advance a `YYYY-MM-DD` string by N days (UTC). Invalid input returns as-is. */
|
||||
export function addDays(date: string, days: number): string {
|
||||
if (!isDateLike(date)) return date;
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function deepClone<T>(v: T): T {
|
||||
return JSON.parse(JSON.stringify(v)) as T;
|
||||
}
|
||||
|
||||
/** The subset of a Nation the prompt needs (keeps the LLM context small). */
|
||||
export function nationForPrompt(n: Nation) {
|
||||
return {
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
type: n.type,
|
||||
stats: n.stats,
|
||||
territory: n.territory,
|
||||
relations: n.relations,
|
||||
};
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
// Domain types — the single source of truth shared by the frontend (src/), the
|
||||
// backend (server/), and the LLM prompt contract. No runtime dependencies here.
|
||||
|
||||
/** A nation's 0–100 status indices. Every value is clamped to 0–100 by the engine. */
|
||||
export interface NationStats {
|
||||
military: number;
|
||||
economy: number;
|
||||
stability: number;
|
||||
population: number;
|
||||
}
|
||||
|
||||
export const STAT_KEYS = ['military', 'economy', 'stability', 'population'] as const;
|
||||
export type StatKey = (typeof STAT_KEYS)[number];
|
||||
|
||||
export type NationType = 'major' | 'minor' | 'power' | 'alliance' | 'faction';
|
||||
|
||||
/** Direction of a bilateral diplomatic relationship. */
|
||||
export type RelationStatus = 'ally' | 'neutral' | 'rival' | 'hostile' | 'war';
|
||||
|
||||
export const RELATION_STATUSES: RelationStatus[] = [
|
||||
'ally',
|
||||
'neutral',
|
||||
'rival',
|
||||
'hostile',
|
||||
'war',
|
||||
];
|
||||
|
||||
/**
|
||||
* A single nation in the world. `territory` holds ISO_A3 codes; the map colors
|
||||
* each of those regions with this nation's stable color (see protocol.nationColor).
|
||||
*/
|
||||
export interface Nation {
|
||||
id: string;
|
||||
name: string;
|
||||
type: NationType;
|
||||
stats: NationStats;
|
||||
territory: string[];
|
||||
relations: Record<string, RelationStatus>;
|
||||
}
|
||||
|
||||
/** Categories a narrative event can take. */
|
||||
export type EventType =
|
||||
| 'military'
|
||||
| 'diplomatic'
|
||||
| 'economic'
|
||||
| 'political'
|
||||
| 'social'
|
||||
| 'disaster'
|
||||
| 'scientific'
|
||||
| 'cultural';
|
||||
|
||||
export const EVENT_TYPES: EventType[] = [
|
||||
'military',
|
||||
'diplomatic',
|
||||
'economic',
|
||||
'political',
|
||||
'social',
|
||||
'disaster',
|
||||
'scientific',
|
||||
'cultural',
|
||||
];
|
||||
|
||||
export type EventOutcome = 'success' | 'failure' | 'partial' | 'neutral';
|
||||
|
||||
/** A narrative event the simulation emitted on some turn. */
|
||||
export interface EventEntry {
|
||||
id: string;
|
||||
turn: number;
|
||||
date: string;
|
||||
nation: string; // nation id the event concerns
|
||||
type: EventType;
|
||||
title: string;
|
||||
text: string;
|
||||
outcome: EventOutcome;
|
||||
}
|
||||
|
||||
/** The full, persisted state of one game. Saved to SQLite by id. */
|
||||
export interface GameState {
|
||||
id: string;
|
||||
presetId: string;
|
||||
presetTitle: string;
|
||||
playerNationId: string;
|
||||
turn: number;
|
||||
date: string;
|
||||
nations: Record<string, Nation>;
|
||||
events: EventEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A scenario the player can start. One JSON file per preset (presets/<id>.json).
|
||||
* `worldBackground` + `simulationRules` become the LLM system prompt.
|
||||
*/
|
||||
export interface Preset {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
summary: string;
|
||||
worldBackground: string;
|
||||
simulationRules: string[];
|
||||
nations: Nation[];
|
||||
}
|
||||
|
||||
/** The lightweight manifest entry used to render the preset picker cards. */
|
||||
export interface PresetMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
summary: string;
|
||||
}
|
||||
Reference in New Issue
Block a user