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