diff --git a/guide/age-of-four/src/simulation/simulation.worker.ts b/guide/age-of-four/src/simulation/simulation.worker.ts deleted file mode 100644 index 74b7faea..00000000 --- a/guide/age-of-four/src/simulation/simulation.worker.ts +++ /dev/null @@ -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 - 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 | null = null -let climateParams: Record | null = null -const scenarioStates = new Map() -const cancelFlags = new Map() - -// 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 { - 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 { - // 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, params: Record): void { - terrainCache = buildTerrainCacheFromData(terrainData) - climateParams = params - post({ type: 'ready' }) -} - -async function handleRun(scenarioId: string, turns: number, prebufferFrameCount: number, _seed = WORLD_SEED): Promise { - 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 { - 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): 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 - } -}