From 4d115e001be266ae660d3d2c3a8c6c07fd6561ef Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 31 Mar 2026 22:47:31 -0700 Subject: [PATCH] =?UTF-8?q?perf(vite-plugins):=20=E2=9A=A1=20Optimize=20Vi?= =?UTF-8?q?te=20build=20performance=20by=20improving=20caching=20in=20simC?= =?UTF-8?q?achePlugin=20and=20worker=20management=20in=20simWorker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../guide/src/vite-plugins/simCachePlugin.ts | 232 ++++++++++++------ .../guide/src/vite-plugins/simWorker.ts | 53 ++++ 2 files changed, 213 insertions(+), 72 deletions(-) create mode 100644 games/age-of-dwarves/guide/src/vite-plugins/simWorker.ts diff --git a/games/age-of-dwarves/guide/src/vite-plugins/simCachePlugin.ts b/games/age-of-dwarves/guide/src/vite-plugins/simCachePlugin.ts index 51d98fcb..7a2e9fb4 100644 --- a/games/age-of-dwarves/guide/src/vite-plugins/simCachePlugin.ts +++ b/games/age-of-dwarves/guide/src/vite-plugins/simCachePlugin.ts @@ -1,47 +1,166 @@ import type { Plugin, ViteDevServer } from 'vite' -import fs from 'fs' -import path from 'path' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import Redis from 'ioredis' /** * Vite dev-only plugin that pre-computes climate simulation scenarios - * on the server and serves cached stats+events via HTTP. + * in child processes (non-blocking) and serves cached results 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. + * Two-layer persistence: + * - In-memory Map: session-scoped, cleared on hot update + * - Redis (black:26402, /bigdisk/redis/magic-civ): 30-day TTL, survives restarts + * + * Pre-warms earth scenarios at server startup so page loads are instant. + * Cache invalidates 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') +const WORKER_PATH = fileURLToPath(new URL('./simWorker.ts', import.meta.url)) +const REDIS_URL = process.env.SIM_CACHE_REDIS ?? 'redis://10.0.0.11:26402' +const REDIS_TTL_SECONDS = 30 * 24 * 60 * 60 - // 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 - } +const PREWARM_IDS = [ + 'hadean_earth', + 'base_no_magic', + 'ice_age', + 'desertification', + 'ecological_collapse', + 'volcanic_winter', +] + +interface SimResult { + stats: unknown[] + events: unknown[][] +} + +type Logger = ViteDevServer['config']['logger'] + +function redisKey(scenarioId: string, seed: number, turns: number): string { + return `sim:${scenarioId}:${seed}:${turns}` +} + +function runChildProcess(input: { + scenarioId: string + seed: number + turns: number + enginePath: string + terrainDir: string + paramsPath: string +}): Promise { + return new Promise((resolve, reject) => { + const child = spawn('node', ['--import', 'tsx/esm', WORKER_PATH], { + env: { ...process.env, WORKER_DATA: JSON.stringify(input) }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + + child.on('close', code => { + if (code !== 0) { + reject(new Error(`sim worker exited ${code}: ${stderr.trim()}`)) + return } - } + try { + resolve(JSON.parse(stdout.trim()) as SimResult) + } catch { + reject(new Error(`sim worker output parse failed: ${stdout.slice(0, 200)}`)) + } + }) + }) +} - // Load climate params from world definition - const paramsPath = path.resolve(root, '../../../src/resources/worlds/earth/climate_params.json') - climateParams = JSON.parse(fs.readFileSync(paramsPath, 'utf8')) as Record +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +export function simCachePlugin(): Plugin { + const cache = new Map() + const pending = new Map>() + + let redis: Redis | null = null + let enginePath = '' + let terrainDir = '' + let paramsPath = '' + + function initPaths(root: string): void { + enginePath = path.resolve(root, '../../../src/packages/engine-ts/src/index.ts') + terrainDir = path.resolve(root, '../../../src/resources/tiles') + paramsPath = path.resolve(root, '../../../src/resources/worlds/earth/climate_params.json') + } + + function readRedis(key: string): Promise { + if (!redis) return Promise.resolve(null) + return redis.get(key) + .then(raw => raw ? JSON.parse(raw) as SimResult : null) + .catch(() => null) + } + + function writeRedis(key: string, result: SimResult, logger: Logger): void { + if (!redis) return + redis.set(key, JSON.stringify(result), 'EX', REDIS_TTL_SECONDS) + .catch((err: unknown) => logger.warn(`[sim-cache] redis write failed for ${key}: ${errMsg(err)}`)) + } + + function compute(scenarioId: string, seed: number, turns: number, logger: Logger): Promise { + const memKey = `${scenarioId}:${seed}:${turns}` + const rKey = redisKey(scenarioId, seed, turns) + + const mem = cache.get(memKey) + if (mem) return Promise.resolve(mem) + + const inFlight = pending.get(memKey) + if (inFlight) return inFlight + + const promise = readRedis(rKey).then(persisted => { + if (persisted) { + cache.set(memKey, persisted) + return persisted + } + return runChildProcess({ scenarioId, seed, turns, enginePath, terrainDir, paramsPath }) + .then(result => { + cache.set(memKey, result) + writeRedis(rKey, result, logger) + return result + }) + }).finally(() => { + pending.delete(memKey) + }) + + pending.set(memKey, promise) + return promise + } + + function prewarm(logger: Logger): void { + for (const id of PREWARM_IDS) { + compute(id, 42, 2000, logger).then( + () => logger.info(`[sim-cache] warmed: ${id}`), + (err: unknown) => logger.warn(`[sim-cache] pre-warm failed for ${id}: ${errMsg(err)}`) + ) + } } return { name: 'magic-civ-sim-cache', - apply: 'serve', // Dev only + apply: 'serve', configureServer(server: ViteDevServer) { + const { logger } = server.config + initPaths(server.config.root) + + redis = new Redis(REDIS_URL, { lazyConnect: true, enableReadyCheck: false, maxRetriesPerRequest: 1 }) + redis.connect() + .then(() => logger.info('[sim-cache] Redis connected')) + .catch((err: unknown) => logger.warn(`[sim-cache] Redis unavailable (cache will not persist): ${errMsg(err)}`)) + + prewarm(logger) + server.middlewares.use((req, res, next) => { const url = req.url ?? '' const match = url.match(/^\/__sim-cache\/([^?]+)/) @@ -51,64 +170,33 @@ export function simCachePlugin(): Plugin { 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') + compute(scenarioId, seed, turns, logger).then( + result => { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ scenarioId, seed, turns, ...result })) + }, + (err: unknown) => { + res.statusCode = 500 + res.end(JSON.stringify({ error: errMsg(err) })) } - 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('/src/packages/engine-ts/src/') || + file.includes('/src/resources/') || file.includes('/games/age-of-dwarves/data/') ) { cache.clear() - engineModule = null - terrainData = null - climateParams = null + pending.clear() } }, + + buildEnd() { + redis?.disconnect() + }, } } diff --git a/games/age-of-dwarves/guide/src/vite-plugins/simWorker.ts b/games/age-of-dwarves/guide/src/vite-plugins/simWorker.ts new file mode 100644 index 00000000..d14e2960 --- /dev/null +++ b/games/age-of-dwarves/guide/src/vite-plugins/simWorker.ts @@ -0,0 +1,53 @@ +/** + * Worker process for climate simulation computation. + * Spawned by simCachePlugin via child_process to run scenarios + * off the main event loop without blocking Vite's dev server. + * + * Input: WORKER_DATA env var (JSON) + * Output: single JSON line written to stdout + */ +import fs from 'node:fs' +import path from 'node:path' + +interface WorkerInput { + scenarioId: string + seed: number + turns: number + enginePath: string + terrainDir: string + paramsPath: string +} + +const input = JSON.parse(process.env.WORKER_DATA ?? '{}') as WorkerInput +const { scenarioId, seed, turns, enginePath, terrainDir, paramsPath } = input + +const terrainData: Record = {} +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[t.id] = t + } +} + +const climateParams = JSON.parse(fs.readFileSync(paramsPath, 'utf8')) as Record + +const engine = await import(enginePath) as typeof import('@magic-civ/engine-ts') +const { SCENARIOS, buildTerrainCacheFromData, runScenarioSync } = engine + +const config = SCENARIOS.find(s => s.id === scenarioId) +if (!config) { + process.stderr.write(`Unknown scenario: ${scenarioId}\n`) + process.exit(1) +} + +const terrainCache = buildTerrainCacheFromData( + terrainData as Record +) +const result = runScenarioSync(config, terrainCache, climateParams, turns, seed) + +process.stdout.write(JSON.stringify({ + stats: result.snapshots.map(s => s.stats), + events: result.snapshots.map(s => s.events), +}) + '\n')