diff --git a/guide/age-of-dwarves/src/hooks/useSimulationWorker.ts b/guide/age-of-dwarves/src/hooks/useSimulationWorker.ts index c90e38ae..a87182fd 100644 --- a/guide/age-of-dwarves/src/hooks/useSimulationWorker.ts +++ b/guide/age-of-dwarves/src/hooks/useSimulationWorker.ts @@ -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 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(null) const [progress, setProgress] = useState(null) const [isReady, setIsReady] = useState(false) + const [lastTriggeredEvent, setLastTriggeredEvent] = useState(null) const workerRef = useRef(null) + const bgWorkersRef = useRef([]) + const bgComputedRef = useRef>(new Set()) // scenarioIds being/done computed by bg workers + const initPayloadRef = useRef<{ terrainData: Record; params: Record; spec: Record; easterEggs: Record } | null>(null) const frameCacheRef = useRef(new FrameCache()) const pendingFrameRef = useRef(null) // "scenarioId:turn" of outstanding request const refFrameRequestedRef = useRef>(new Set()) + const lastSeedRef = useRef>(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): void => { + const bgWorker = new SimulationWorker() + bgWorkersRef.current.push(bgWorker) + + bgWorker.onmessage = (e: MessageEvent): 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): 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): 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> 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 }> + const eggMods = import.meta.glob( + '@data/seed_easter_eggs.json', + { eager: true, import: 'default' }, + ) as Record + 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, } }