diff --git a/guide/age-of-dwarves/src/vite-plugins/simCachePlugin.ts b/guide/age-of-dwarves/src/vite-plugins/simCachePlugin.ts new file mode 100644 index 00000000..21924289 --- /dev/null +++ b/guide/age-of-dwarves/src/vite-plugins/simCachePlugin.ts @@ -0,0 +1,118 @@ +import type { Plugin, ViteDevServer } from 'vite' +import fs from 'fs' +import path from 'path' + +/** + * Vite dev-only plugin that pre-computes climate simulation scenarios + * on the server and serves cached stats+events via HTTP. + * + * Endpoint: GET /__sim-cache/:scenarioId?seed=42&turns=2000 + * Response: { scenarioId, seed, turns, stats, events } + * + * Cache invalidation: clears when engine source or game data files change. + */ +export function simCachePlugin(): Plugin { + const cache = new Map() + let engineModule: typeof import('@magic-civ/engine-ts') | null = null + let terrainData: Record | null = null + let climateParams: Record | null = null + + function loadGameData(root: string): void { + const dataDir = path.resolve(root, '../../games/age-of-dwarves/data') + + // Load terrain files + const terrainDir = path.join(dataDir, 'terrain') + terrainData = {} + for (const file of fs.readdirSync(terrainDir).filter(f => f.endsWith('.json'))) { + const raw = JSON.parse(fs.readFileSync(path.join(terrainDir, file), 'utf8')) as { terrains?: Array<{ id: string }> } + if (raw.terrains) { + for (const t of raw.terrains) { + (terrainData as Record)[t.id] = t + } + } + } + + // Load climate params (extract only numeric values) + const paramsPath = path.join(dataDir, 'climate_params.json') + const rawParams = JSON.parse(fs.readFileSync(paramsPath, 'utf8')) as Record + climateParams = {} + for (const [k, v] of Object.entries(rawParams)) { + if (typeof v === 'number') climateParams[k] = v + } + } + + return { + name: 'magic-civ-sim-cache', + apply: 'serve', // Dev only + + configureServer(server: ViteDevServer) { + server.middlewares.use((req, res, next) => { + const url = req.url ?? '' + const match = url.match(/^\/__sim-cache\/([^?]+)/) + if (!match) { next(); return } + + const scenarioId = match[1] + const params = new URL(url, 'http://localhost').searchParams + const seed = Number(params.get('seed') ?? '42') + const turns = Number(params.get('turns') ?? '2000') + const cacheKey = `${scenarioId}:${seed}:${turns}` + + // Return cached result if available + const cached = cache.get(cacheKey) + if (cached) { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ scenarioId, seed, turns, stats: cached.stats, events: cached.events })) + return + } + + // Lazy-load engine and data on first request (Vite resolves TS imports for us) + const computeAndRespond = async (): Promise => { + if (!engineModule) { + const enginePath = path.resolve(server.config.root, '../../packages/engine-ts/src/index.ts') + engineModule = await server.ssrLoadModule(enginePath) as typeof import('@magic-civ/engine-ts') + } + if (!terrainData || !climateParams) { + loadGameData(server.config.root) + } + + const { SCENARIOS, buildTerrainCacheFromData, runScenarioSync } = engineModule + const config = SCENARIOS.find(s => s.id === scenarioId) + if (!config) { + res.statusCode = 404 + res.end(JSON.stringify({ error: `Unknown scenario: ${scenarioId}` })) + return + } + + const terrainCache = buildTerrainCacheFromData(terrainData as Record) + const result = runScenarioSync(config, terrainCache, climateParams!, turns, seed) + + const stats = result.snapshots.map(s => s.stats) + const events = result.snapshots.map(s => s.events) + + cache.set(cacheKey, { stats, events }) + + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ scenarioId, seed, turns, stats, events })) + } + + computeAndRespond().catch((err: unknown) => { + res.statusCode = 500 + res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })) + }) + }) + }, + + handleHotUpdate({ file }) { + // Invalidate cache when engine source or game data changes + if ( + file.includes('/packages/engine-ts/src/') || + file.includes('/games/age-of-dwarves/data/') + ) { + cache.clear() + engineModule = null + terrainData = null + climateParams = null + } + }, + } +}