perf(age-dwarves): Optimize Web Worker task scheduling for simulation tasks to improve responsiveness and reduce resource usage

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-28 21:31:38 -07:00
parent a71ce96052
commit 4a691fa29d

View file

@ -1,6 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import type { TurnStats, EcologicalEvent, TerrainData } from '@magic-civ/engine-ts'
import type { WorkerResponse, FramePayload } from '@magic-civ/engine-ts'
import type { WorkerResponse, FramePayload, EventTriggeredResponse } from '@magic-civ/engine-ts'
import { SCENARIOS, DEFAULT_SCENARIO_TURNS } from '@magic-civ/engine-ts'
import { getCache, putCache, pruneStaleEntries } from '@/simulation/simCache'
import type { CachedScenario } from '@/simulation/simCache'
import SimulationWorker from '../simulation/simulation.worker.ts?worker'
// ---------------------------------------------------------------------------
// Types
@ -16,24 +21,34 @@ export interface ScenarioData {
export interface SimProgress {
scenarioId: string
pct: number
phase: 'geology' | 'scenario' | 'buffering'
phase: 'generating' | 'geology' | 'scenario' | 'buffering'
/** For buffering phase: frames encoded so far. */
bufferedFrames?: number
/** For buffering phase: total frames to pre-encode. */
totalBufferFrames?: number
}
export type { EventTriggeredResponse }
export interface TriggeredEventInfo {
category: string
tier: number
turn: number
}
export interface UseSimulationWorkerResult {
scenarios: Map<string, ScenarioData>
currentFrame: FramePayload | null
referenceFrame: FramePayload | null
progress: SimProgress | null
isReady: boolean
lastTriggeredEvent: TriggeredEventInfo | null
runScenario: (scenarioId: string, turns: number, bufferFrames: number, seed?: number) => void
extendScenario: (scenarioId: string, turns: number) => void
requestFrame: (scenarioId: string, turn: number) => void
cancelScenario: (scenarioId: string) => void
triggerEvent: (scenarioId: string, category: string, tier: number) => void
}
// ---------------------------------------------------------------------------
@ -103,18 +118,93 @@ export function useSimulationWorker(): UseSimulationWorkerResult {
const [referenceFrame, setReferenceFrame] = useState<FramePayload | null>(null)
const [progress, setProgress] = useState<SimProgress | null>(null)
const [isReady, setIsReady] = useState(false)
const [lastTriggeredEvent, setLastTriggeredEvent] = useState<TriggeredEventInfo | null>(null)
const workerRef = useRef<Worker | null>(null)
const bgWorkersRef = useRef<Worker[]>([])
const bgComputedRef = useRef<Set<string>>(new Set()) // scenarioIds being/done computed by bg workers
const initPayloadRef = useRef<{ terrainData: Record<string, TerrainData>; params: Record<string, number>; spec: Record<string, unknown>; easterEggs: Record<string, unknown> } | null>(null)
const frameCacheRef = useRef(new FrameCache())
const pendingFrameRef = useRef<string | null>(null) // "scenarioId:turn" of outstanding request
const refFrameRequestedRef = useRef<Set<string>>(new Set())
const lastSeedRef = useRef<Map<string, number>>(new Map())
const wasExtendedRunRef = useRef(false)
// ── Background speculative pre-computation ──────────────────────────────
// Defined before useEffect so HMR partial re-evaluation keeps references stable.
const speculateSingle = (scenarioId: string, seed: number, payload: NonNullable<typeof initPayloadRef.current>): void => {
const bgWorker = new SimulationWorker()
bgWorkersRef.current.push(bgWorker)
bgWorker.onmessage = (e: MessageEvent<WorkerResponse>): void => {
const msg = e.data
if (msg.type === 'ready') {
bgWorker.postMessage({ type: 'run', scenarioId, turns: DEFAULT_SCENARIO_TURNS, prebufferFrames: 0, seed })
} else if (msg.type === 'stats') {
putCache(msg.scenarioId, seed, msg.allStats.length, msg.allStats, msg.allEvents)
.catch((err: unknown) => console.error('[SimCache] bg write failed:', err))
} else if (msg.type === 'prebuffer_done') {
bgWorker.terminate()
bgWorkersRef.current = bgWorkersRef.current.filter(w => w !== bgWorker)
const remaining = SCENARIOS.filter(s => !bgComputedRef.current.has(s.id))
if (remaining.length > 0) {
bgComputedRef.current.add(remaining[0].id)
speculateSingle(remaining[0].id, seed, payload)
}
}
}
bgWorker.postMessage({ type: 'init', terrainData: payload.terrainData, params: payload.params, spec: payload.spec, easterEggs: payload.easterEggs })
}
const speculateOtherScenarios = (completedId: string): void => {
const payload = initPayloadRef.current
if (!payload) return
const seed = lastSeedRef.current.get(completedId) ?? 42
const otherScenarios = SCENARIOS.filter(s => s.id !== completedId && !bgComputedRef.current.has(s.id))
const MAX_BG = 2
const toCompute = otherScenarios.slice(0, MAX_BG)
for (const scenario of toCompute) {
bgComputedRef.current.add(scenario.id)
getCache(scenario.id, seed)
.then((cached) => {
if (cached && cached.turns >= DEFAULT_SCENARIO_TURNS) return
const bgWorker = new SimulationWorker()
bgWorkersRef.current.push(bgWorker)
bgWorker.onmessage = (e: MessageEvent<WorkerResponse>): void => {
const msg = e.data
if (msg.type === 'ready') {
bgWorker.postMessage({ type: 'run', scenarioId: scenario.id, turns: DEFAULT_SCENARIO_TURNS, prebufferFrames: 0, seed })
} else if (msg.type === 'stats') {
putCache(msg.scenarioId, seed, msg.allStats.length, msg.allStats, msg.allEvents)
.catch((err: unknown) => console.error('[SimCache] bg write failed:', err))
} else if (msg.type === 'prebuffer_done') {
bgWorker.terminate()
bgWorkersRef.current = bgWorkersRef.current.filter(w => w !== bgWorker)
const remaining = SCENARIOS.filter(s => s.id !== completedId && !bgComputedRef.current.has(s.id))
if (remaining.length > 0) {
bgComputedRef.current.add(remaining[0].id)
speculateSingle(remaining[0].id, seed, payload)
}
}
}
bgWorker.postMessage({ type: 'init', terrainData: payload.terrainData, params: payload.params, spec: payload.spec, easterEggs: payload.easterEggs })
})
.catch((err: unknown) => console.error('[SimCache] bg check failed:', err))
}
}
// ── Initialize worker ──────────────────────────────────────────────────
useEffect(() => {
const worker = new Worker(
new URL('../simulation/simulation.worker.ts', import.meta.url),
{ type: 'module' },
)
const worker = new SimulationWorker()
workerRef.current = worker
worker.onmessage = (e: MessageEvent<WorkerResponse>): void => {
@ -154,6 +244,12 @@ export function useSimulationWorker(): UseSimulationWorkerResult {
})
return next
})
// Persist to IndexedDB for cross-refresh caching
{
const seed = lastSeedRef.current.get(msg.scenarioId) ?? 42
putCache(msg.scenarioId, seed, msg.allStats.length, msg.allStats, msg.allEvents)
.catch((err: unknown) => console.error('[SimCache] write failed:', err))
}
break
case 'done':
@ -178,6 +274,11 @@ export function useSimulationWorker(): UseSimulationWorkerResult {
return next
})
setProgress(null)
// Skip background speculation when the main run used more than the default
// turn count — three concurrent workers with extended state causes OOM.
if (!wasExtendedRunRef.current) {
speculateOtherScenarios(msg.scenarioId)
}
break
case 'frames': {
@ -202,6 +303,9 @@ export function useSimulationWorker(): UseSimulationWorkerResult {
break
}
case 'event_triggered':
setLastTriggeredEvent({ category: msg.eventCategory, tier: msg.tier, turn: msg.turn })
break
case 'error':
console.error(`[SimWorker] ${msg.scenarioId}: ${msg.message}`)
setProgress(null)
@ -244,14 +348,30 @@ export function useSimulationWorker(): UseSimulationWorkerResult {
) as Record<string, Record<string, unknown>>
const spec = Object.values(specMods)[0] ?? {}
worker.postMessage({ type: 'init', terrainData, params, spec })
// Load seed_easter_eggs.json (game-pack-owned — engine just supports the interface)
type RawEasterEggs = Record<string, { name: string; flavor: string; mapType?: string; paramOverrides?: Record<string, number> }>
const eggMods = import.meta.glob(
'@data/seed_easter_eggs.json',
{ eager: true, import: 'default' },
) as Record<string, RawEasterEggs>
const easterEggs = Object.values(eggMods)[0] ?? {}
initPayloadRef.current = { terrainData, params, spec, easterEggs }
worker.postMessage({ type: 'init', terrainData, params, spec, easterEggs })
} catch (err) {
console.error('[SimWorker] Failed to load data:', err)
}
// Prune stale IDB entries on startup
pruneStaleEntries().catch((err: unknown) => {
console.error('[SimCache] prune failed:', err)
})
return () => {
worker.terminate()
workerRef.current = null
for (const bgWorker of bgWorkersRef.current) bgWorker.terminate()
bgWorkersRef.current = []
}
}, [])
@ -260,11 +380,61 @@ export function useSimulationWorker(): UseSimulationWorkerResult {
const runScenario = useCallback((scenarioId: string, turns: number, bufferFrames: number, seed?: number) => {
const worker = workerRef.current
if (!worker) return
frameCacheRef.current.clearScenario(scenarioId)
refFrameRequestedRef.current.delete(scenarioId)
// Only clear frame cache if the seed changed — frames are deterministic by (scenario, seed, turn)
const prevSeed = lastSeedRef.current.get(scenarioId)
if (seed !== prevSeed) {
frameCacheRef.current.clearScenario(scenarioId)
refFrameRequestedRef.current.delete(scenarioId)
}
const resolvedSeed = seed ?? 42
lastSeedRef.current.set(scenarioId, resolvedSeed)
wasExtendedRunRef.current = turns > DEFAULT_SCENARIO_TURNS
// Try to hydrate stats from cache (dev middleware → IDB) before worker finishes
hydrateFromCache(scenarioId, resolvedSeed, turns)
// Always send run to worker — it handles its own redundancy check and prebuffering
worker.postMessage({ type: 'run', scenarioId, turns, prebufferFrames: bufferFrames, seed })
}, [])
/** Try dev middleware first, then IndexedDB. Hydrates stats instantly if found. */
function hydrateFromCache(scenarioId: string, seed: number, turns: number): void {
const hydrate = (data: { stats: TurnStats[]; events: EcologicalEvent[][]; turns: number }): void => {
setScenarios((prev) => {
const next = new Map(prev)
const existing = next.get(scenarioId)
// Don't overwrite if worker already delivered results
if (existing?.bufferReady) return prev
next.set(scenarioId, {
stats: data.stats,
events: data.events,
totalTurns: data.turns,
bufferReady: false,
})
return next
})
}
// 1. Try dev middleware (only exists in dev, 404s in production)
fetch(`/__sim-cache/${scenarioId}?seed=${seed}&turns=${turns}`)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() as { stats: TurnStats[]; events: EcologicalEvent[][]; turns: number }
hydrate(data)
// Write to IDB so future refreshes don't need the middleware
putCache(scenarioId, seed, data.turns, data.stats, data.events)
.catch((err: unknown) => console.error('[SimCache] write after middleware:', err))
})
.catch(() => {
// 2. Fall back to IndexedDB
getCache(scenarioId, seed)
.then((cached: CachedScenario | null) => {
if (cached && cached.turns >= turns) hydrate(cached)
})
.catch((err: unknown) => console.error('[SimCache] IDB read failed:', err))
})
}
const extendScenario = useCallback((scenarioId: string, turns: number) => {
const worker = workerRef.current
if (!worker) return
@ -304,15 +474,23 @@ export function useSimulationWorker(): UseSimulationWorkerResult {
worker.postMessage({ type: 'cancel', scenarioId })
}, [])
const triggerEvent = useCallback((scenarioId: string, category: string, tier: number) => {
const worker = workerRef.current
if (!worker) return
worker.postMessage({ type: 'trigger_event', scenarioId, eventCategory: category, tier })
}, [])
return {
scenarios,
currentFrame,
referenceFrame,
progress,
isReady,
lastTriggeredEvent,
runScenario,
extendScenario,
requestFrame,
cancelScenario,
triggerEvent,
}
}