feat(simulation): Add new Web Worker-based simulation mechanics and optimize computation efficiency

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 11:38:31 -07:00
parent c1936d3f29
commit d65f68e10a

View file

@ -1,446 +0,0 @@
import type {
GridState,
TurnStats,
EcologicalEvent,
TerrainData,
ScenarioConfig,
WorkerCommand,
WorkerResponse,
FramePayload,
} from '@magic-civ/engine-ts'
import {
SCENARIOS,
buildTerrainCacheFromData,
cloneGridState,
encodeSnapshot,
computeTurnStats,
applyVolcanicWinterForcing,
generate as generateMap,
ClimatePhysics,
EcologyPhysics,
GRID_WIDTH,
GRID_HEIGHT,
} from '@magic-civ/engine-ts'
// ---------------------------------------------------------------------------
// Worker-internal scenario state
// ---------------------------------------------------------------------------
interface ScenarioState {
config: ScenarioConfig
stats: TurnStats[]
events: EcologicalEvent[][]
checkpoints: Map<number, GridState>
grid: GridState
physics: ClimatePhysics
ecology: EcologyPhysics | null
cursorScenarioTurn: number
totalScenarioTurns: number
worldSeed: number
isVolcanicWinter: boolean
}
const CHUNK_SIZE = 50
const CHECKPOINT_INTERVAL = 100
const MAX_CHECKPOINTS = 20
const WORLD_SEED = 42
const PREBUFFER_BATCH = 50
let terrainCache: Map<string, TerrainData> | null = null
let climateParams: Record<string, number> | null = null
const scenarioStates = new Map<string, ScenarioState>()
const cancelFlags = new Map<string, boolean>()
// Map generation uses the transpiled GDScript pipeline — same seed = same world
// ---------------------------------------------------------------------------
// 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) applyVolcanicWinterForcing(state.grid)
const events = state.physics.processStep(state.grid, absTurn, state.worldSeed)
if (state.ecology) state.ecology.processStep(state.grid)
const prevStats = state.stats.length > 0 ? state.stats[state.stats.length - 1] : undefined
const stats = computeTurnStats(state.grid, undefined, prevStats)
state.cursorScenarioTurn = scenarioTurn + 1
return { stats, events }
}
function addCheckpoint(state: ScenarioState, turn: number): void {
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) applyVolcanicWinterForcing(state.grid)
state.physics.processStep(state.grid, absTurn, state.worldSeed)
if (state.ecology) state.ecology.processStep(state.grid)
}
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
}
const cloned = cloneGridState(cp)
state.grid.tiles = cloned.tiles
state.grid.global_avg_temp = cloned.global_avg_temp
state.grid.ocean_dead_fraction = cloned.ocean_dead_fraction
state.cursorScenarioTurn = bestCpTurn
for (let st = bestCpTurn; st < targetSt; st++) {
const absTurn = state.config.worldAge + st
if (state.isVolcanicWinter) applyVolcanicWinterForcing(state.grid)
state.physics.processStep(state.grid, absTurn, state.worldSeed)
if (state.ecology) state.ecology.processStep(state.grid)
}
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
const tmpGrid = cloneGridState(cp0)
const tmpPhysics = new ClimatePhysics(climateParams, terrainCache)
const tmpEcology = new EcologyPhysics()
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) applyVolcanicWinterForcing(tmpGrid)
tmpPhysics.processStep(tmpGrid, absTurn, state.worldSeed)
tmpEcology.processStep(tmpGrid)
}
const snap = encodeSnapshot(tmpGrid, st)
frames.push({
texA: snap.texA,
texB: snap.texB,
texC: snap.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,
})
transferables.push(snap.texA.buffer as ArrayBuffer, snap.texB.buffer as ArrayBuffer, snap.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()
}
} finally {
post({ type: 'prebuffer_done', scenarioId })
}
}
// ---------------------------------------------------------------------------
// Command handlers
// ---------------------------------------------------------------------------
function handleInit(terrainData: Record<string, TerrainData>, params: Record<string, number>): void {
terrainCache = buildTerrainCacheFromData(terrainData)
climateParams = params
post({ type: 'ready' })
}
async function handleRun(scenarioId: string, turns: number, prebufferFrameCount: number, _seed = WORLD_SEED): Promise<void> {
if (!terrainCache || !climateParams) {
postError(scenarioId, 'Worker not initialized — call init first')
return
}
cancelFlags.set(scenarioId, false)
const config = SCENARIOS.find((s) => s.id === scenarioId)
if (!config) {
postError(scenarioId, `Unknown scenario: ${scenarioId}`)
return
}
const worldAge = config.worldAge
const totalAbsTurns = worldAge + turns
const isVolcanicWinter = config.id === 'volcanic_winter'
// Generate map from seed using transpiled GDScript pipeline
const grid = generateMap(WORLD_SEED, GRID_WIDTH, GRID_HEIGHT, terrainCache, climateParams, 'continents')
const physics = new ClimatePhysics(climateParams, terrainCache)
const ecology = new EcologyPhysics()
// Phase 1: Geological history (climate + ecology)
for (let turn = 0; turn < worldAge; turn++) {
if (cancelFlags.get(scenarioId)) return
physics.processStep(grid, turn, WORLD_SEED)
ecology.processStep(grid)
if (turn % CHUNK_SIZE === 0) {
post({ type: 'progress', scenarioId, turn, total: totalAbsTurns, phase: 'geology' })
await yieldToMessageLoop()
}
}
if (cancelFlags.get(scenarioId)) return
// Phase 2: Apply scenario overrides
config.initMap(grid)
// Phase 3: Scenario simulation
const state: ScenarioState = {
config, grid, physics,
ecology,
stats: [], events: [],
checkpoints: new Map(),
cursorScenarioTurn: 0,
totalScenarioTurns: turns,
worldSeed: WORLD_SEED,
isVolcanicWinter,
}
addCheckpoint(state, 0)
for (let st = 0; st < turns; st++) {
if (cancelFlags.get(scenarioId)) return
if (st > 0 && st % CHECKPOINT_INTERVAL === 0) {
addCheckpoint(state, st)
}
const { stats, events } = stepOneTurn(state, st)
state.stats.push(stats)
state.events.push(events)
if (st % CHUNK_SIZE === 0) {
post({ type: 'progress', scenarioId, turn: worldAge + st, total: totalAbsTurns, phase: 'scenario' })
await yieldToMessageLoop()
}
}
scenarioStates.set(scenarioId, state)
post({
type: 'stats', scenarioId,
allStats: state.stats,
allEvents: state.events,
})
post({ type: 'done', scenarioId, totalTurns: turns })
await prebufferFrames(state, scenarioId, prebufferFrameCount)
}
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
}
cancelFlags.set(scenarioId, false)
const startSt = state.totalScenarioTurns
const endSt = startSt + turns
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)
}
const { stats, events } = stepOneTurn(state, st)
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' })
await yieldToMessageLoop()
}
}
state.totalScenarioTurns = endSt
post({
type: 'stats', scenarioId,
allStats: state.stats,
allEvents: state.events,
})
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
}
const count = Math.min(lookahead, state.totalScenarioTurns - turn)
if (count <= 0) return
seekToTurn(state, turn)
// Encode frames from a temporary grid copy so we don't pollute the cursor
const tmpGrid = cloneGridState(state.grid)
const tmpPhysics = new ClimatePhysics(climateParams, terrainCache)
const tmpEcology = new EcologyPhysics()
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) applyVolcanicWinterForcing(tmpGrid)
tmpPhysics.processStep(tmpGrid, absTurn, state.worldSeed)
tmpEcology.processStep(tmpGrid)
}
const snap = encodeSnapshot(tmpGrid, st)
frames.push({
texA: snap.texA,
texB: snap.texB,
texC: snap.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,
})
transferables.push(snap.texA.buffer as ArrayBuffer, snap.texB.buffer as ArrayBuffer, snap.texC.buffer as ArrayBuffer)
}
post(
{ type: 'frames', scenarioId, startTurn: turn, snapshots: frames },
transferables,
)
}
function handleCancel(scenarioId: string): void {
cancelFlags.set(scenarioId, true)
}
// ---------------------------------------------------------------------------
// Message dispatcher
// ---------------------------------------------------------------------------
self.onmessage = (e: MessageEvent<WorkerCommand>): void => {
const cmd = e.data
switch (cmd.type) {
case 'init':
handleInit(cmd.terrainData, cmd.params)
break
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
case 'extend':
handleExtend(cmd.scenarioId, cmd.turns).catch((err: unknown) => {
postError(cmd.scenarioId, err instanceof Error ? err.message : String(err))
})
break
case 'frame':
handleFrame(cmd.scenarioId, cmd.turn, cmd.lookahead)
break
case 'cancel':
handleCancel(cmd.scenarioId)
break
}
}