perf(simulation): Refactor web worker simulation logic for faster Age of Dwarves guide performance

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-07 21:40:27 -07:00
parent e98827c3d1
commit 3595f86338

View file

@ -1,11 +1,8 @@
import type {
GridState,
TurnStats,
EcologicalEvent,
TerrainData,
ScenarioConfig,
WorkerCommand,
WorkerResponse,
FramePayload,
EasterEggSeed,
EventCatalog,
@ -27,74 +24,17 @@ import {
EventEvaluator,
DEFAULT_DT,
} from '@magic-civ/engine-ts'
import { CHUNK_SIZE, CHECKPOINT_INTERVAL, WORLD_SEED, STREAM_START_TURNS, STREAM_BATCH } from './worker/types'
import type { ScenarioState } from './worker/types'
import { post, postError, yieldToMessageLoop } from './worker/messaging'
import { jsGridToWasm, wasmGridToJs, wasmPhysicsStep, addCheckpoint, seekToTurn, stepOneTurn } from './worker/grid-bridge'
import { prebufferFrames } from './worker/prebuffer'
import { applyTierEffectsToGrid } from './worker/event-trigger'
// ---------------------------------------------------------------------------
// Worker-internal scenario state
// Module-level caches (worker-global)
// ---------------------------------------------------------------------------
interface ScenarioState {
config: ScenarioConfig
stats: TurnStats[]
events: EcologicalEvent[][]
timings: number[]
checkpoints: Map<number, GridState>
grid: GridState
wasmGrid: WasmGrid
physics: WasmClimatePhysics
ecology: WasmEcologyPhysics | null
eventEvaluator: EventEvaluator | null
cursorScenarioTurn: number
totalScenarioTurns: number
worldSeed: number
isVolcanicWinter: boolean
dt: number
}
const CHUNK_SIZE = 5
const CHECKPOINT_INTERVAL = 100
const MAX_CHECKPOINTS = 20
const WORLD_SEED = 42
const PREBUFFER_BATCH = 50
/**
* Number of scenario turns to compute before the first inline streaming batch
* is posted and `prebuffer_ready` fires. 50 turns gives ~5 s of initial
* playback while the rest of the simulation continues in the background.
*/
const STREAM_START_TURNS = 50
/**
* After the first streaming batch, flush another batch every this many turns.
*/
const STREAM_BATCH = 100
// ---------------------------------------------------------------------------
// WASM ↔ JS grid bridging
// ---------------------------------------------------------------------------
function jsGridToWasm(grid: GridState): WasmGrid {
return WasmGrid.fromJSONString(JSON.stringify(grid))
}
function wasmGridToJs(wg: WasmGrid): GridState {
return JSON.parse(wg.toJSONString()) as GridState
}
/** Run one WASM physics step — uses spec stored inside physics, no per-turn JSON parse. */
function wasmPhysicsStep(
physics: WasmClimatePhysics,
ecology: WasmEcologyPhysics | null,
wasmGrid: WasmGrid,
turn: number,
seed: number,
dt: number,
): void {
physics.processStep(wasmGrid, turn, seed, dt)
if (ecology) ecology.processStep(wasmGrid, dt)
physics.stepAtmosphericChemistry(wasmGrid)
}
let terrainCache: Map<string, TerrainData> | null = null
let climateParams: Record<string, unknown> | null = null
let climateSpec: Record<string, unknown> = {}
@ -102,250 +42,20 @@ let easterEggs: Record<string, EasterEggSeed> = {}
let allPlanetParams: Record<string, Record<string, unknown>> = {}
let allPlanetSpecs: Record<string, Record<string, unknown>> = {}
// JSON strings cached for WASM construction
let terrainJson = '{}'
let climateParamsJson = '{}'
let climateSpecJson = '{}'
const scenarioStates = new Map<string, ScenarioState>()
const cancelFlags = new Map<string, boolean>()
// ---------------------------------------------------------------------------
// Deterministic caches — same inputs always produce identical output
// ---------------------------------------------------------------------------
// Map generation: seed:mapType → freshly generated grid (before atmosphere/geology)
// Deterministic map + geology caches
const mapCache = new Map<string, GridState>()
// Geology phase: (seed, worldAge, atmosphere, abioticWorld) → grid after geology
const geologyCache = new Map<string, GridState>()
function geologyKey(seed: number, worldAge: number, atmo: string, abiotic: boolean): string {
return `${seed}:${worldAge}:${atmo}:${abiotic}`
}
// ---------------------------------------------------------------------------
// Messaging
// ---------------------------------------------------------------------------
function post(msg: WorkerResponse, transfer?: Transferable[]): void {
if (transfer) {
// Worker postMessage with transferables — cast needed for TS DOM lib compat
(self.postMessage as (message: unknown, transfer: Transferable[]) => void)(msg, transfer)
} else {
self.postMessage(msg)
}
}
function postError(scenarioId: string, message: string): void {
post({ type: 'error', scenarioId, message })
}
// ---------------------------------------------------------------------------
// Yield helper — lets the worker process incoming messages between chunks
// ---------------------------------------------------------------------------
function yieldToMessageLoop(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
// ---------------------------------------------------------------------------
// Simulation primitives
// ---------------------------------------------------------------------------
function stepOneTurn(state: ScenarioState, scenarioTurn: number): { stats: TurnStats; events: EcologicalEvent[] } {
const absTurn = state.config.worldAge + scenarioTurn
if (state.isVolcanicWinter) {
// Volcanic winter forcing operates on JS grid, then sync to WASM
state.grid = wasmGridToJs(state.wasmGrid)
applyVolcanicWinterForcing(state.grid)
state.wasmGrid.free()
state.wasmGrid = jsGridToWasm(state.grid)
}
wasmPhysicsStep(state.physics, state.ecology, state.wasmGrid, absTurn, state.worldSeed, state.dt)
// Compute stats in Rust — avoids full 960-tile JSON deserialization every turn.
const prevStats = state.stats.length > 0 ? state.stats[state.stats.length - 1] : undefined
const stats: TurnStats = JSON.parse(
state.wasmGrid.computeStatsJson(prevStats?.avg_temp ?? 0.5, prevStats?.avg_moisture ?? 0.5)
)
state.cursorScenarioTurn = scenarioTurn + 1
// Fire stochastic events — only deserialize JS grid when events would fire or
// there are active multi-turn effects already running.
let events: EcologicalEvent[] = []
const evaluator = state.eventEvaluator
if (evaluator !== null && (evaluator.checkWillFire(absTurn, state.dt) || evaluator.hasActiveEffects())) {
state.grid = wasmGridToJs(state.wasmGrid)
events = evaluator.evaluateTurn(state.grid, absTurn, state.dt)
if (events.length > 0) {
// Sync any event-driven grid mutations back to WASM
state.wasmGrid.free()
state.wasmGrid = jsGridToWasm(state.grid)
}
}
return { stats, events }
}
function addCheckpoint(state: ScenarioState, turn: number): void {
// Sync JS grid from WASM before checkpointing — checkpoints are infrequent (every 100 turns)
// so the toJSON cost here is acceptable. The per-turn hot path avoids this.
state.grid = wasmGridToJs(state.wasmGrid)
state.checkpoints.set(turn, cloneGridState(state.grid))
if (state.checkpoints.size > MAX_CHECKPOINTS) {
let oldestKey = -1
let oldestTurn = Infinity
for (const key of state.checkpoints.keys()) {
if (key > 0 && key < oldestTurn) {
oldestTurn = key
oldestKey = key
}
}
if (oldestKey >= 0) state.checkpoints.delete(oldestKey)
}
}
function seekToTurn(state: ScenarioState, targetSt: number): void {
if (targetSt === state.cursorScenarioTurn) return
if (targetSt > state.cursorScenarioTurn) {
for (let st = state.cursorScenarioTurn; st < targetSt; st++) {
const absTurn = state.config.worldAge + st
if (state.isVolcanicWinter) {
state.grid = wasmGridToJs(state.wasmGrid)
applyVolcanicWinterForcing(state.grid)
state.wasmGrid.free()
state.wasmGrid = jsGridToWasm(state.grid)
}
wasmPhysicsStep(state.physics, state.ecology, state.wasmGrid, absTurn, state.worldSeed, state.dt)
}
state.grid = wasmGridToJs(state.wasmGrid)
state.cursorScenarioTurn = targetSt
return
}
// Backward — find nearest checkpoint, replay forward
let bestCpTurn = 0
for (const cpTurn of state.checkpoints.keys()) {
if (cpTurn <= targetSt && cpTurn > bestCpTurn) bestCpTurn = cpTurn
}
const cp = state.checkpoints.get(bestCpTurn)
if (!cp) {
postError(state.config.id, `No checkpoint found for backward seek to turn ${targetSt}`)
return
}
state.grid = cloneGridState(cp)
state.wasmGrid.free()
state.wasmGrid = jsGridToWasm(state.grid)
state.cursorScenarioTurn = bestCpTurn
for (let st = bestCpTurn; st < targetSt; st++) {
const absTurn = state.config.worldAge + st
if (state.isVolcanicWinter) {
state.grid = wasmGridToJs(state.wasmGrid)
applyVolcanicWinterForcing(state.grid)
state.wasmGrid.free()
state.wasmGrid = jsGridToWasm(state.grid)
}
wasmPhysicsStep(state.physics, state.ecology, state.wasmGrid, absTurn, state.worldSeed, state.dt)
}
state.grid = wasmGridToJs(state.wasmGrid)
state.cursorScenarioTurn = targetSt
}
// ---------------------------------------------------------------------------
// Pre-buffer: encode first N frames after simulation completes
// ---------------------------------------------------------------------------
async function prebufferFrames(state: ScenarioState, scenarioId: string, frameCount: number): Promise<void> {
// Always post prebuffer_done so bufferReady is never stuck false
try {
if (!terrainCache || !climateParams) return
const count = Math.min(frameCount, state.totalScenarioTurns)
if (count <= 0) return
// Start from checkpoint at turn 0 so we don't disturb the cursor
const cp0 = state.checkpoints.get(0)
if (!cp0) return
let tmpWasmGrid = jsGridToWasm(cp0)
const tmpPhysics = new WasmClimatePhysics(climateParamsJson, terrainJson, climateSpecJson)
const tmpEcology = state.config.abioticWorld ? null : new WasmEcologyPhysics()
const n = cp0.tiles.length
for (let batchStart = 0; batchStart < count; batchStart += PREBUFFER_BATCH) {
if (cancelFlags.get(scenarioId)) break
const batchEnd = Math.min(batchStart + PREBUFFER_BATCH, count)
const frames: FramePayload[] = []
const transferables: ArrayBuffer[] = []
for (let st = batchStart; st < batchEnd; st++) {
if (st > 0) {
const absTurn = state.config.worldAge + st
if (state.isVolcanicWinter) {
const tmpGrid = wasmGridToJs(tmpWasmGrid)
applyVolcanicWinterForcing(tmpGrid)
tmpWasmGrid.free()
tmpWasmGrid = jsGridToWasm(tmpGrid)
}
wasmPhysicsStep(tmpPhysics, tmpEcology, tmpWasmGrid, absTurn, state.worldSeed, state.dt)
}
// Use writeFrameBuffers for the texture data (hot path)
const texA = new Float32Array(n * 4)
const texB = new Float32Array(n * 4)
const texC = new Float32Array(n * 4)
tmpPhysics.writeFrameBuffers(tmpWasmGrid, texA, texB, texC)
// Get JS grid for non-texture data (river segments, wind arrows, stats)
const tmpGrid = wasmGridToJs(tmpWasmGrid)
const snap = encodeSnapshot(tmpGrid, st)
frames.push({
texA,
texB,
texC,
width: snap.width,
height: snap.height,
turn: snap.turn,
global_avg_temp: snap.global_avg_temp,
ocean_dead_fraction: snap.ocean_dead_fraction,
ley_edges: snap.ley_edges,
wonder_positions: snap.wonder_positions,
riverSegments: snap.riverSegments,
riverSources: snap.riverSources,
windArrows: snap.windArrows,
})
transferables.push(texA.buffer as ArrayBuffer, texB.buffer as ArrayBuffer, texC.buffer as ArrayBuffer)
}
post(
{ type: 'frames', scenarioId, startTurn: batchStart, snapshots: frames },
transferables,
)
post({
type: 'progress',
scenarioId,
turn: batchEnd,
total: count,
phase: 'buffering',
})
await yieldToMessageLoop()
}
tmpWasmGrid.free()
tmpPhysics.free()
if (tmpEcology) tmpEcology.free()
} finally {
post({ type: 'prebuffer_done', scenarioId })
}
}
// ---------------------------------------------------------------------------
// Command handlers
// ---------------------------------------------------------------------------
@ -364,12 +74,9 @@ function handleInit(
easterEggs = eggs ?? {}
allPlanetParams = planetParams ?? {}
allPlanetSpecs = planetSpecs ?? {}
// Cache JSON strings for WASM construction
terrainJson = JSON.stringify(terrainData)
climateParamsJson = JSON.stringify(params)
climateSpecJson = JSON.stringify(climateSpec)
post({ type: 'ready' })
}
@ -379,47 +86,32 @@ async function handleRun(scenarioId: string, turns: number, prebufferFrameCount:
return
}
// Skip redundant re-runs: if this exact scenario was already computed with
// the same seed and at least as many turns, re-send cached results.
const existing = scenarioStates.get(scenarioId)
if (existing && existing.worldSeed === seed && existing.totalScenarioTurns >= turns) {
post({ type: 'stats', scenarioId, allStats: existing.stats, allEvents: existing.events, allTimings: existing.timings })
post({ type: 'done', scenarioId, totalTurns: existing.totalScenarioTurns })
await prebufferFrames(existing, scenarioId, prebufferFrameCount)
await prebufferFrames(existing, scenarioId, prebufferFrameCount, cancelFlags, terrainCache, climateParams, climateParamsJson, terrainJson, climateSpecJson)
return
}
cancelFlags.set(scenarioId, false)
const config = SCENARIOS.find((s) => s.id === scenarioId)
if (!config) {
postError(scenarioId, `Unknown scenario: ${scenarioId}`)
return
}
if (!config) { postError(scenarioId, `Unknown scenario: ${scenarioId}`); return }
const worldAge = config.worldAge
const totalAbsTurns = worldAge + turns
const isVolcanicWinter = config.id === 'volcanic_winter'
const dt = config.dt ?? DEFAULT_DT
// Planet-specific params override earth defaults when available
const planet = config.planet ?? 'earth'
const baseParams = allPlanetParams[planet] ?? climateParams
const baseSpec = planet !== 'earth' && allPlanetSpecs[planet]
? { ...allPlanetSpecs[planet] }
: climateSpec
const baseSpec = planet !== 'earth' && allPlanetSpecs[planet] ? { ...allPlanetSpecs[planet] } : climateSpec
// Easter egg overrides (data from game pack, received at init)
const easterEgg = easterEggs[String(seed)]
const effectiveMapType = easterEgg?.mapType ?? 'continents'
const effectiveParams = easterEgg?.paramOverrides
? { ...baseParams, ...easterEgg.paramOverrides }
: baseParams
// Compute JSON strings for WASM construction (may include planet/easter egg overrides)
const effectiveParams = easterEgg?.paramOverrides ? { ...baseParams, ...easterEgg.paramOverrides } : baseParams
const effectiveParamsJson = JSON.stringify(effectiveParams)
const baseSpecJson = JSON.stringify(baseSpec)
// Generate map from seed (cached — deterministic by seed+mapType)
post({ type: 'progress', scenarioId, turn: 0, total: 1, phase: 'generating' })
let grid: GridState
const mapCacheKey = `${seed}:${effectiveMapType}`
@ -437,7 +129,6 @@ async function handleRun(scenarioId: string, turns: number, prebufferFrameCount:
}
post({ type: 'progress', scenarioId, turn: 1, total: 1, phase: 'generating' })
// Initialize atmospheric state from scenario config
const atmoKey = config.startingAtmosphere ?? 'modern'
const atmoInit = STARTING_ATMOSPHERES[atmoKey] ?? STARTING_ATMOSPHERES.modern
grid.o2_fraction = atmoInit.o2
@ -448,82 +139,53 @@ async function handleRun(scenarioId: string, turns: number, prebufferFrameCount:
grid.global_fish_stock = 1.0
grid.photosynthesisMultiplier = 1.0
// Phase 1: Geological history (cached — deterministic by seed+worldAge+atmo+abiotic)
// Phase 1: Geological history (cached)
const geoKey = geologyKey(seed, worldAge, atmoKey, !!config.abioticWorld)
const cachedGeology = geologyCache.get(geoKey)
if (cachedGeology) {
grid = cloneGridState(cachedGeology)
} else {
// Run geology entirely in WASM — only convert back at the end
const geoWasmGrid = jsGridToWasm(grid)
const geoPhysics = new WasmClimatePhysics(effectiveParamsJson, terrainJson, baseSpecJson)
const geoEcology = config.abioticWorld ? null : new WasmEcologyPhysics()
for (let turn = 0; turn < worldAge; turn++) {
if (cancelFlags.get(scenarioId)) {
geoWasmGrid.free()
geoPhysics.free()
if (geoEcology) geoEcology.free()
return
}
if (cancelFlags.get(scenarioId)) { geoWasmGrid.free(); geoPhysics.free(); if (geoEcology) geoEcology.free(); return }
wasmPhysicsStep(geoPhysics, geoEcology, geoWasmGrid, turn, seed, dt)
if (turn % CHUNK_SIZE === 0) {
post({ type: 'progress', scenarioId, turn, total: totalAbsTurns, phase: 'geology' })
await yieldToMessageLoop()
}
if (turn % CHUNK_SIZE === 0) { post({ type: 'progress', scenarioId, turn, total: totalAbsTurns, phase: 'geology' }); await yieldToMessageLoop() }
}
grid = wasmGridToJs(geoWasmGrid)
geoWasmGrid.free()
geoPhysics.free()
if (geoEcology) geoEcology.free()
if (worldAge > 0) {
geologyCache.set(geoKey, cloneGridState(grid))
}
geoWasmGrid.free(); geoPhysics.free(); if (geoEcology) geoEcology.free()
if (worldAge > 0) geologyCache.set(geoKey, cloneGridState(grid))
}
if (cancelFlags.get(scenarioId)) return
// Phase 2: Apply scenario overrides
config.initMap(grid)
// Re-classify biomes after initMap zeroed biology (canopy=0 → abiotic biomes only)
if (config.abioticWorld) {
for (const tile of grid.tiles) {
tile.biome_id = classifyBiome(tile)
}
for (const tile of grid.tiles) tile.biome_id = classifyBiome(tile)
}
// Phase 3: Scenario simulation — fresh WASM physics instances
// Phase 3: Scenario simulation
const physics = new WasmClimatePhysics(effectiveParamsJson, terrainJson, baseSpecJson)
const ecology = config.abioticWorld ? null : new WasmEcologyPhysics()
const wasmGrid = jsGridToWasm(grid)
// Build event evaluator from ecological_events catalog loaded at init
const ecologicalEvents = baseSpec.ecological_events as Record<string, unknown> | undefined
let eventEvaluator: EventEvaluator | null = null
if (ecologicalEvents) {
const { cross_triggers: crossTriggersRaw, ...rawCategories } = ecologicalEvents
// Filter to valid CategorySpec entries only (must have base_frequency + severity_weights)
const eventCategories: EventCatalog = {}
for (const [name, spec] of Object.entries(rawCategories)) {
if (
spec !== null && typeof spec === 'object' &&
typeof (spec as Record<string, unknown>)['base_frequency'] === 'number' &&
Array.isArray((spec as Record<string, unknown>)['severity_weights'])
) {
if (spec !== null && typeof spec === 'object' && typeof (spec as Record<string, unknown>)['base_frequency'] === 'number' && Array.isArray((spec as Record<string, unknown>)['severity_weights'])) {
eventCategories[name] = spec as EventCatalog[string]
}
}
eventEvaluator = new EventEvaluator(
eventCategories,
(crossTriggersRaw ?? {}) as CrossTriggers,
seed,
)
eventEvaluator = new EventEvaluator(eventCategories, (crossTriggersRaw ?? {}) as CrossTriggers, seed)
}
const state: ScenarioState = {
config, grid, wasmGrid, physics,
ecology, eventEvaluator,
config, grid, wasmGrid, physics, ecology, eventEvaluator,
stats: [], events: [], timings: [],
checkpoints: new Map(),
cursorScenarioTurn: 0,
@ -534,12 +196,9 @@ async function handleRun(scenarioId: string, turns: number, prebufferFrameCount:
}
addCheckpoint(state, 0)
// Register state early so handleFrame can serve requests once streaming begins.
scenarioStates.set(scenarioId, state)
const n = state.grid.tiles.length
// Inline streaming: frames are encoded directly from the current WASM state as
// turns are computed — no separate WASM instance, no cursor corruption.
let streamFrames: FramePayload[] = []
let streamTransferables: ArrayBuffer[] = []
let streamNextFlushTurn = STREAM_START_TURNS
@ -547,10 +206,7 @@ async function handleRun(scenarioId: string, turns: number, prebufferFrameCount:
for (let st = 0; st < turns; st++) {
if (cancelFlags.get(scenarioId)) return
if (st > 0 && st % CHECKPOINT_INTERVAL === 0) {
addCheckpoint(state, st)
}
if (st > 0 && st % CHECKPOINT_INTERVAL === 0) addCheckpoint(state, st)
const t0 = performance.now()
const { stats, events } = stepOneTurn(state, st)
@ -558,8 +214,6 @@ async function handleRun(scenarioId: string, turns: number, prebufferFrameCount:
state.stats.push(stats)
state.events.push(events)
// Encode this turn's frame inline from the current WASM state.
// writeFrameBuffers + wasmGridToJs are read-only on the WASM state — no cursor corruption.
const texA = new Float32Array(n * 4)
const texB = new Float32Array(n * 4)
const texC = new Float32Array(n * 4)
@ -573,128 +227,76 @@ async function handleRun(scenarioId: string, turns: number, prebufferFrameCount:
streamTransferables.push(texA.buffer as ArrayBuffer, texB.buffer as ArrayBuffer, texC.buffer as ArrayBuffer)
const computedCount = st + 1
const shouldFlush = !streamReady
? computedCount >= STREAM_START_TURNS
: computedCount >= streamNextFlushTurn
const shouldFlush = !streamReady ? computedCount >= STREAM_START_TURNS : computedCount >= streamNextFlushTurn
if (shouldFlush && streamFrames.length > 0) {
post(
{ type: 'frames', scenarioId, startTurn: streamFrames[0]!.turn, snapshots: streamFrames, availableTurns: computedCount },
streamTransferables,
)
if (!streamReady) {
streamReady = true
post({ type: 'prebuffer_ready', scenarioId, availableTurns: computedCount })
}
post({ type: 'frames', scenarioId, startTurn: streamFrames[0]!.turn, snapshots: streamFrames, availableTurns: computedCount }, streamTransferables)
if (!streamReady) { streamReady = true; post({ type: 'prebuffer_ready', scenarioId, availableTurns: computedCount }) }
streamNextFlushTurn = computedCount + STREAM_BATCH
streamFrames = []
streamTransferables = []
streamFrames = []; streamTransferables = []
await yieldToMessageLoop()
}
if (st % CHUNK_SIZE === 0) {
post({ type: 'progress', scenarioId, turn: worldAge + st, total: totalAbsTurns, phase: 'scenario' })
await yieldToMessageLoop()
}
if (st % CHUNK_SIZE === 0) { post({ type: 'progress', scenarioId, turn: worldAge + st, total: totalAbsTurns, phase: 'scenario' }); await yieldToMessageLoop() }
}
// Flush any remaining inline frames before handing off to prebuffer.
if (streamFrames.length > 0) {
post(
{ type: 'frames', scenarioId, startTurn: streamFrames[0]!.turn, snapshots: streamFrames, availableTurns: turns },
streamTransferables,
)
if (!streamReady) {
streamReady = true
post({ type: 'prebuffer_ready', scenarioId, availableTurns: turns })
}
post({ type: 'frames', scenarioId, startTurn: streamFrames[0]!.turn, snapshots: streamFrames, availableTurns: turns }, streamTransferables)
if (!streamReady) { streamReady = true; post({ type: 'prebuffer_ready', scenarioId, availableTurns: turns }) }
}
scenarioStates.set(scenarioId, state)
post({
type: 'stats', scenarioId,
allStats: state.stats,
allEvents: state.events,
allTimings: state.timings,
})
post({ type: 'stats', scenarioId, allStats: state.stats, allEvents: state.events, allTimings: state.timings })
post({ type: 'done', scenarioId, totalTurns: turns })
// prebuffer encodes the requested buffer window; frames are idempotent in the
// main-thread LRU cache. prebuffer_done is still required to clear the progress
// indicator and trigger speculative background workers.
await prebufferFrames(state, scenarioId, prebufferFrameCount)
await prebufferFrames(state, scenarioId, prebufferFrameCount, cancelFlags, terrainCache, climateParams, climateParamsJson, terrainJson, climateSpecJson)
}
async function handleExtend(scenarioId: string, turns: number): Promise<void> {
const state = scenarioStates.get(scenarioId)
if (!state) {
postError(scenarioId, 'Scenario not yet simulated — call run first')
return
}
if (!state) { postError(scenarioId, 'Scenario not yet simulated — call run first'); return }
cancelFlags.set(scenarioId, false)
const startSt = state.totalScenarioTurns
const endSt = startSt + turns
if (state.cursorScenarioTurn < startSt) {
seekToTurn(state, startSt)
}
if (state.cursorScenarioTurn < startSt) seekToTurn(state, startSt)
for (let st = startSt; st < endSt; st++) {
if (cancelFlags.get(scenarioId)) return
if (st % CHECKPOINT_INTERVAL === 0) {
addCheckpoint(state, st)
}
if (st % CHECKPOINT_INTERVAL === 0) addCheckpoint(state, st)
const t0 = performance.now()
const { stats, events } = stepOneTurn(state, st)
state.timings.push(performance.now() - t0)
state.stats.push(stats)
state.events.push(events)
if ((st - startSt) % CHUNK_SIZE === 0) {
const total = state.config.worldAge + endSt
post({ type: 'progress', scenarioId, turn: state.config.worldAge + st, total, phase: 'scenario' })
post({ type: 'progress', scenarioId, turn: state.config.worldAge + st, total: state.config.worldAge + endSt, phase: 'scenario' })
await yieldToMessageLoop()
}
}
state.totalScenarioTurns = endSt
post({
type: 'stats', scenarioId,
allStats: state.stats,
allEvents: state.events,
allTimings: state.timings,
})
post({ type: 'stats', scenarioId, allStats: state.stats, allEvents: state.events, allTimings: state.timings })
post({ type: 'done', scenarioId, totalTurns: endSt })
}
function handleFrame(scenarioId: string, turn: number, lookahead: number): void {
const state = scenarioStates.get(scenarioId)
if (!state || !terrainCache || !climateParams) {
postError(scenarioId, 'Scenario not ready for frame request')
return
}
if (!state || !terrainCache || !climateParams) { postError(scenarioId, 'Scenario not ready for frame request'); return }
const count = Math.min(lookahead, state.totalScenarioTurns - turn)
if (count <= 0) return
seekToTurn(state, turn)
// Encode frames from a temporary WASM grid copy so we don't pollute the cursor
let tmpWasmGrid = jsGridToWasm(state.grid)
const tmpPhysics = new WasmClimatePhysics(climateParamsJson, terrainJson, climateSpecJson)
const tmpEcology = state.config.abioticWorld ? null : new WasmEcologyPhysics()
const n = state.grid.tiles.length
const frames: FramePayload[] = []
const transferables: ArrayBuffer[] = []
for (let i = 0; i < count; i++) {
const st = turn + i
if (i > 0) {
const absTurn = state.config.worldAge + st
if (state.isVolcanicWinter) {
@ -705,121 +307,47 @@ function handleFrame(scenarioId: string, turn: number, lookahead: number): void
}
wasmPhysicsStep(tmpPhysics, tmpEcology, tmpWasmGrid, absTurn, state.worldSeed, state.dt)
}
// Use writeFrameBuffers for texture data
const texA = new Float32Array(n * 4)
const texB = new Float32Array(n * 4)
const texC = new Float32Array(n * 4)
tmpPhysics.writeFrameBuffers(tmpWasmGrid, texA, texB, texC)
// Get JS grid for non-texture data
const tmpGrid = wasmGridToJs(tmpWasmGrid)
const snap = encodeSnapshot(tmpGrid, st)
frames.push({
texA,
texB,
texC,
width: snap.width,
height: snap.height,
turn: snap.turn,
global_avg_temp: snap.global_avg_temp,
ocean_dead_fraction: snap.ocean_dead_fraction,
ley_edges: snap.ley_edges,
wonder_positions: snap.wonder_positions,
riverSegments: snap.riverSegments,
riverSources: snap.riverSources,
windArrows: snap.windArrows,
})
frames.push({ texA, texB, texC, width: snap.width, height: snap.height, turn: snap.turn,
global_avg_temp: snap.global_avg_temp, ocean_dead_fraction: snap.ocean_dead_fraction,
ley_edges: snap.ley_edges, wonder_positions: snap.wonder_positions,
riverSegments: snap.riverSegments, riverSources: snap.riverSources, windArrows: snap.windArrows })
transferables.push(texA.buffer as ArrayBuffer, texB.buffer as ArrayBuffer, texC.buffer as ArrayBuffer)
}
tmpWasmGrid.free()
tmpPhysics.free()
if (tmpEcology) tmpEcology.free()
post(
{ type: 'frames', scenarioId, startTurn: turn, snapshots: frames },
transferables,
)
post({ type: 'frames', scenarioId, startTurn: turn, snapshots: frames }, transferables)
}
function handleCancel(scenarioId: string): void {
cancelFlags.set(scenarioId, true)
}
// ---------------------------------------------------------------------------
// Event injection helpers
// ---------------------------------------------------------------------------
function applyTierEffectsToGrid(grid: GridState, tierData: Record<string, unknown>): void {
const o2Delta = typeof tierData.o2_delta === 'number' ? tierData.o2_delta : 0
const co2Gain = typeof tierData.co2_gain === 'number' ? tierData.co2_gain : 0
const ch4Pulse = typeof tierData.ch4_pulse === 'number' ? tierData.ch4_pulse : 0
const globalHeat = typeof tierData.global_heat === 'number' ? tierData.global_heat : 0
const aerosolStr = typeof tierData.aerosol_strength === 'number' ? tierData.aerosol_strength : 0
const aerosolGlobal = tierData.aerosol_global === true
const photoMult = typeof tierData.photosynthesis_multiplier === 'number' ? tierData.photosynthesis_multiplier : null
if (o2Delta !== 0)
grid.o2_fraction = Math.max(0.0001, Math.min(0.35, (grid.o2_fraction ?? 0.21) + o2Delta))
if (co2Gain !== 0)
grid.co2_ppm = Math.max(0, (grid.co2_ppm ?? 420) + co2Gain)
if (ch4Pulse !== 0)
grid.ch4_ppb = Math.max(0, (grid.ch4_ppb ?? 1900) + ch4Pulse)
if (photoMult !== null)
grid.photosynthesisMultiplier = Math.min(grid.photosynthesisMultiplier ?? 1.0, photoMult)
const setFlagStr = typeof tierData.sets_flag === 'string' ? tierData.sets_flag : null
const setFlagsArr = Array.isArray(tierData.sets_flags) ? (tierData.sets_flags as string[]) : []
const allFlags = setFlagStr ? [setFlagStr, ...setFlagsArr] : setFlagsArr
for (const flag of allFlags) {
if (flag === 'ecological_collapse') grid.ecological_collapse = true
else if (flag === 'ocean_toxic') grid.ocean_toxic = true
}
if (aerosolGlobal && aerosolStr > 0) {
for (const tile of grid.tiles)
tile.sulfate_aerosol = Math.min(1.0, (tile.sulfate_aerosol ?? 0) + aerosolStr)
}
if (globalHeat !== 0) {
for (const tile of grid.tiles)
tile.magic_heat_delta = (tile.magic_heat_delta ?? 0) + globalHeat * 0.01
}
}
async function handleTriggerEvent(scenarioId: string, eventCategory: string, tier: number): Promise<void> {
const state = scenarioStates.get(scenarioId)
if (!state || !climateParams || !terrainCache) {
postError(scenarioId, 'Scenario not ready for event trigger — run scenario first')
return
}
if (!state || !climateParams || !terrainCache) { postError(scenarioId, 'Scenario not ready for event trigger — run scenario first'); return }
const eventsSpec = (climateSpec?.ecological_events as Record<string, unknown> | undefined)
const categorySpec = eventsSpec?.[eventCategory] as Record<string, unknown> | undefined
if (!categorySpec) {
postError(scenarioId, `Unknown event category: ${eventCategory}`)
return
}
if (!categorySpec) { postError(scenarioId, `Unknown event category: ${eventCategory}`); return }
const tiers = categorySpec.tiers as Record<string, Record<string, unknown>> | undefined
const tiers = categorySpec.tiers as Record<string, Record<string, unknown>> | undefined
const tierData = tiers?.[String(tier)]
if (!tierData) {
postError(scenarioId, `Tier ${tier} not found in category '${eventCategory}'`)
return
}
if (!tierData) { postError(scenarioId, `Tier ${tier} not found in category '${eventCategory}'`); return }
// Seek to current simulation end so effects land at the latest state
seekToTurn(state, state.totalScenarioTurns)
// Apply tier effects directly to the live JS grid, then sync to WASM
applyTierEffectsToGrid(state.grid, tierData)
state.wasmGrid.free()
state.wasmGrid = jsGridToWasm(state.grid)
post({ type: 'event_triggered', scenarioId, eventCategory, tier, turn: state.totalScenarioTurns })
// Extend by 5 turns so effects propagate and new frames/stats are delivered
await handleExtend(scenarioId, 5)
}
@ -836,7 +364,6 @@ self.onmessage = (e: MessageEvent<WorkerCommand>): void => {
case 'run':
handleRun(cmd.scenarioId, cmd.turns, cmd.prebufferFrames, cmd.seed).catch((err: unknown) => {
postError(cmd.scenarioId, err instanceof Error ? err.message : String(err))
// Ensure bufferReady is never stuck false even if handleRun itself threw
post({ type: 'prebuffer_done', scenarioId: cmd.scenarioId })
})
break
@ -860,7 +387,5 @@ self.onmessage = (e: MessageEvent<WorkerCommand>): void => {
}
// Signal the main thread that this module has fully evaluated and onmessage is
// registered. The main thread waits for this before posting `init`, ensuring
// messages are never lost during module load (Chrome drops queued messages
// posted to a module worker before its top-level await chain completes).
// registered. The main thread waits for this before posting `init`.
self.postMessage({ type: 'module_ready' })