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
+172
View File
@@ -0,0 +1,172 @@
import { useEffect, useRef } from 'react';
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { RegionMap } from '@shared/protocol';
// Minimal GeoJSON types (avoids a hard @types/geojson dependency).
interface GeoFeature {
type: 'Feature';
id?: string | number;
properties?: Record<string, unknown>;
geometry: unknown;
}
interface FeatureCollection {
type: 'FeatureCollection';
features: GeoFeature[];
}
// Remote OpenFreeMap dark basemap ("command center" look).
const STYLE_URL = 'https://tiles.openfreemap.org/styles/dark';
const COUNTRIES_SOURCE = 'countries';
const FILL_LAYER = 'countries-fill';
const LINE_LAYER = 'countries-line';
function applyRegions(
geo: FeatureCollection,
regions: RegionMap,
playerNationId: string,
): FeatureCollection {
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
for (const f of out.features) {
f.properties = f.properties ?? {};
const r = f.id ? regions[f.id as string] : undefined;
if (r) {
f.properties.fill = r.color;
f.properties.owner = r.owner;
// Highlight the player's regions with a bright stroke.
f.properties.stroke = r.ownerId === playerNationId ? '#fbbf24' : 'rgba(255,255,255,0.18)';
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
} else {
f.properties.fill = 'rgba(148,163,184,0.06)';
f.properties.stroke = 'rgba(255,255,255,0.08)';
f.properties.owner = '';
f.properties.isPlayer = 0;
}
}
return out;
}
interface Props {
regions: RegionMap;
playerNationId: string;
className?: string;
}
/**
* World basemap (MapLibre GL) + a country choropleth colored by the owning
* nation. Ported from flight's Map.tsx (remote style + idempotent `ensureLayers`
* re-establish on `styledata`), stripped of all flight-specific data/logic.
*/
export default function WorldMap({ regions, playerNationId, className }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const baseGeoRef = useRef<FeatureCollection | null>(null);
// Latest props, so the mount effect's (re)establish closure always sees them.
const regionsRef = useRef(regions);
const playerRef = useRef(playerNationId);
regionsRef.current = regions;
playerRef.current = playerNationId;
useEffect(() => {
if (!containerRef.current) return;
let cancelled = false;
const map = new maplibregl.Map({
container: containerRef.current,
style: STYLE_URL,
center: [15, 50],
zoom: 2.5,
minZoom: 1.25,
maxZoom: 8,
attributionControl: { compact: true },
});
mapRef.current = map;
const loadGeo = (): Promise<FeatureCollection> =>
baseGeoRef.current
? Promise.resolve(baseGeoRef.current)
: fetch('/countries.geo.json')
.then((r) => {
if (!r.ok) throw new Error(`countries.geo.json → HTTP ${r.status}`);
return r.json();
})
.then((g: FeatureCollection) => {
baseGeoRef.current = g;
return g;
});
const onCountryClick = (e: maplibregl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
const p = f.properties as { owner?: string; name?: string; isPlayer?: number };
const label = p.owner ? `${p.owner}${p.isPlayer ? ' (your nation)' : ''}` : p.name ?? 'Unclaimed';
new maplibregl.Popup({ offset: 8 })
.setLngLat(f.geometry.coordinates as [number, number])
.setText(label)
.addTo(map);
};
// Idempotent: re-adds the countries source + layers after any setStyle, and on
// first load. (Vite serves MapLibre's worker automatically, so no setWorkerUrl.)
const ensureLayers = async () => {
let geo: FeatureCollection;
try {
geo = await loadGeo();
} catch (err) {
console.error('failed to load countries geojson', err);
return;
}
if (cancelled) return;
if (!map.getSource(COUNTRIES_SOURCE)) {
map.addSource(COUNTRIES_SOURCE, {
type: 'geojson',
data: applyRegions(geo, regionsRef.current, playerRef.current),
});
}
if (!map.getLayer(FILL_LAYER)) {
map.addLayer({
id: FILL_LAYER,
type: 'fill',
source: COUNTRIES_SOURCE,
paint: {
'fill-color': ['get', 'fill'],
'fill-opacity': 0.55,
},
});
}
if (!map.getLayer(LINE_LAYER)) {
map.addLayer({
id: LINE_LAYER,
type: 'line',
source: COUNTRIES_SOURCE,
paint: {
'line-color': ['get', 'stroke'],
'line-width': 0.75,
},
});
}
map.off('click', FILL_LAYER, onCountryClick);
map.on('click', FILL_LAYER, onCountryClick);
};
if (map.isStyleLoaded()) void ensureLayers();
else map.on('styledata', () => void ensureLayers());
return () => {
cancelled = true;
map.remove();
mapRef.current = null;
};
}, []);
// Re-color the choropleth whenever the region ownership changes (each turn).
useEffect(() => {
const map = mapRef.current;
const geo = baseGeoRef.current;
if (!map || !geo) return;
const src = map.getSource(COUNTRIES_SOURCE) as maplibregl.GeoJSONSource | undefined;
if (src) void src.setData(applyRegions(geo, regions, playerNationId));
}, [regions, playerNationId]);
return <div ref={containerRef} data-testid="world-map" className={className} />;
}