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:
parent
e98827c3d1
commit
3595f86338
1 changed files with 48 additions and 523 deletions
|
|
@ -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' })
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue