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:
parent
a71ce96052
commit
4a691fa29d
1 changed files with 187 additions and 9 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue