feat(engine): Regenerate climate/ecology physics, update biome classification, and enhance engine runner/exports for improved simulation and performance

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-30 22:27:12 -07:00
parent 8dd8682314
commit 4e670f38c9
6 changed files with 221 additions and 4167 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,151 @@
// Biome classification utilities — extracted from auto-generated EcologyPhysics.
// These are pure functions that classify tiles into biome IDs based on climate/terrain state.
// The heavy physics (flora/fauna simulation) is now in Rust WASM; these utilities remain
// in TS for runner.ts, worker, and test consumption.
import type { TileState } from './types'
import { hasTag } from './biomeRegistry'
// ---------------------------------------------------------------------------
// Biome definitions
// ---------------------------------------------------------------------------
export interface BiomeDef {
id: string
temp_range: [number, number]
moisture_range: [number, number]
flora_climax: { canopy: number; undergrowth: number; fungi: number }
fauna_capacity: number
quality_range: [number, number]
}
export const BIOME_DEFS: Record<string, BiomeDef> = {
deep_ocean: { id: 'deep_ocean', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 8, quality_range: [1, 4] },
shallow_ocean: { id: 'shallow_ocean', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.0 }, fauna_capacity: 12, quality_range: [1, 5] },
coral_reef: { id: 'coral_reef', temp_range: [0.55, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.6, fungi: 0.1 }, fauna_capacity: 20, quality_range: [1, 5] },
estuary: { id: 'estuary', temp_range: [0.2, 0.8], moisture_range: [0.6, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.5, fungi: 0.05 }, fauna_capacity: 14, quality_range: [1, 4] },
lake: { id: 'lake', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.02 }, fauna_capacity: 10, quality_range: [1, 4] },
pond: { id: 'pond', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.15, fungi: 0.01 }, fauna_capacity: 3, quality_range: [1, 2] },
river: { id: 'river', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.2, fungi: 0.01 }, fauna_capacity: 6, quality_range: [1, 3] },
mangrove: { id: 'mangrove', temp_range: [0.55, 1.0], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.6, undergrowth: 0.5, fungi: 0.15 }, fauna_capacity: 14, quality_range: [1, 4] },
tropical_rainforest: { id: 'tropical_rainforest', temp_range: [0.65, 1.0], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.95, undergrowth: 0.7, fungi: 0.4 }, fauna_capacity: 25, quality_range: [1, 5] },
tropical_dry_forest: { id: 'tropical_dry_forest', temp_range: [0.55, 1.0], moisture_range: [0.4, 0.7], flora_climax: { canopy: 0.65, undergrowth: 0.5, fungi: 0.2 }, fauna_capacity: 16, quality_range: [1, 4] },
savanna: { id: 'savanna', temp_range: [0.55, 1.0], moisture_range: [0.2, 0.4], flora_climax: { canopy: 0.15, undergrowth: 0.45, fungi: 0.05 }, fauna_capacity: 12, quality_range: [1, 3] },
desert: { id: 'desert', temp_range: [0.55, 1.0], moisture_range: [0.0, 0.15], flora_climax: { canopy: 0.0, undergrowth: 0.08, fungi: 0.01 }, fauna_capacity: 4, quality_range: [1, 3] },
temperate_forest: { id: 'temperate_forest', temp_range: [0.25, 0.55], moisture_range: [0.5, 1.0], flora_climax: { canopy: 0.85, undergrowth: 0.6, fungi: 0.35 }, fauna_capacity: 18, quality_range: [1, 5] },
temperate_grassland: { id: 'temperate_grassland', temp_range: [0.25, 0.55], moisture_range: [0.3, 0.5], flora_climax: { canopy: 0.05, undergrowth: 0.55, fungi: 0.1 }, fauna_capacity: 14, quality_range: [1, 4] },
chaparral: { id: 'chaparral', temp_range: [0.25, 0.55], moisture_range: [0.15, 0.35], flora_climax: { canopy: 0.1, undergrowth: 0.35, fungi: 0.05 }, fauna_capacity: 8, quality_range: [1, 3] },
swamp: { id: 'swamp', temp_range: [0.35, 0.7], moisture_range: [0.8, 1.0], flora_climax: { canopy: 0.5, undergrowth: 0.6, fungi: 0.45 }, fauna_capacity: 15, quality_range: [1, 4] },
bog: { id: 'bog', temp_range: [0.1, 0.4], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.05, undergrowth: 0.3, fungi: 0.2 }, fauna_capacity: 6, quality_range: [1, 3] },
boreal_forest: { id: 'boreal_forest', temp_range: [0.1, 0.3], moisture_range: [0.35, 1.0], flora_climax: { canopy: 0.7, undergrowth: 0.35, fungi: 0.3 }, fauna_capacity: 12, quality_range: [1, 4] },
tundra: { id: 'tundra', temp_range: [0.05, 0.15], moisture_range: [0.0, 0.5], flora_climax: { canopy: 0.0, undergrowth: 0.15, fungi: 0.05 }, fauna_capacity: 5, quality_range: [1, 3] },
polar_desert: { id: 'polar_desert', temp_range: [0.0, 0.05], moisture_range: [0.0, 0.2], flora_climax: { canopy: 0.0, undergrowth: 0.02, fungi: 0.0 }, fauna_capacity: 2, quality_range: [1, 2] },
montane_forest: { id: 'montane_forest', temp_range: [0.15, 0.45], moisture_range: [0.4, 1.0], flora_climax: { canopy: 0.75, undergrowth: 0.45, fungi: 0.25 }, fauna_capacity: 14, quality_range: [1, 4] },
cloud_forest: { id: 'cloud_forest', temp_range: [0.2, 0.45], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.8, undergrowth: 0.65, fungi: 0.5 }, fauna_capacity: 20, quality_range: [1, 5] },
alpine_meadow: { id: 'alpine_meadow', temp_range: [0.05, 0.25], moisture_range: [0.3, 0.7], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.08 }, fauna_capacity: 6, quality_range: [1, 3] },
alpine_tundra: { id: 'alpine_tundra', temp_range: [0.0, 0.15], moisture_range: [0.0, 0.4], flora_climax: { canopy: 0.0, undergrowth: 0.08, fungi: 0.02 }, fauna_capacity: 3, quality_range: [1, 2] },
sea_ice: { id: 'sea_ice', temp_range: [0.0, 0.08], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 1, quality_range: [1, 1] },
glacial: { id: 'glacial', temp_range: [0.0, 0.1], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.01, fungi: 0.0 }, fauna_capacity: 1, quality_range: [1, 1] },
subterranean: { id: 'subterranean', temp_range: [0.1, 0.5], moisture_range: [0.2, 0.8], flora_climax: { canopy: 0.0, undergrowth: 0.1, fungi: 0.6 }, fauna_capacity: 8, quality_range: [1, 4] },
ocean: { id: 'ocean', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 8, quality_range: [1, 4] },
coast: { id: 'coast', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.1, fungi: 0.0 }, fauna_capacity: 6, quality_range: [1, 3] },
volcanic: { id: 'volcanic', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 0, quality_range: [1, 2] },
enchanted_forest: { id: 'enchanted_forest', temp_range: [0.2, 0.7], moisture_range: [0.4, 1.0], flora_climax: { canopy: 0.85, undergrowth: 0.6, fungi: 0.4 }, fauna_capacity: 18, quality_range: [1, 5] },
snow: { id: 'snow', temp_range: [0.0, 0.1], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 1, quality_range: [1, 1] },
ice: { id: 'ice', temp_range: [0.0, 0.12], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 0, quality_range: [1, 1] },
mana_node: { id: 'mana_node', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 0, quality_range: [1, 5] },
}
export function getBiome(biomeId: string): BiomeDef | null {
return BIOME_DEFS[biomeId] ?? null
}
export function isWater(tile: TileState): boolean {
return hasTag(tile.biome_id ?? '', 'is_water')
}
// ---------------------------------------------------------------------------
// Biome classifier helpers
// ---------------------------------------------------------------------------
function classifyAquatic(tile: TileState): string {
const WATER_BOIL_TEMP = 0.82
if (tile.temperature >= WATER_BOIL_TEMP) return 'volcanic'
const wbType = tile.water_body_type
const biome = tile.biome_id
if (wbType === 'pond' || biome === 'pond') return 'pond'
if (wbType === 'river' || biome === 'river') return 'river'
if (wbType === 'lake' || wbType === 'large_lake' || biome === 'lake' || biome === 'inland_sea') return 'lake'
const depth = tile.depth_from_coast
const temp = tile.temperature
if (tile.is_river_mouth && depth <= 1) return 'estuary'
if (temp > 0.55 && depth <= 2) return 'coral_reef'
if (depth > 3) return 'deep_ocean'
return 'shallow_ocean'
}
function classifyLand(tile: TileState): string {
const temp = tile.temperature
const moisture = tile.moisture
const elevation = tile.elevation
const canopy = tile.canopy_cover
if (moisture > 0.7 && elevation < 0.4 && canopy > 0) {
return temp > 0.4 ? 'swamp' : 'bog'
}
if (elevation > 0.85) return temp < 0.1 ? 'glacial' : 'alpine_tundra'
if (elevation > 0.70) return (canopy > 0 && moisture > 0.3) ? 'alpine_meadow' : 'alpine_tundra'
if (elevation > 0.55) {
if (canopy > 0.4) return 'montane_forest'
if (canopy > 0 && moisture > 0.7 && temp > 0.3) return 'cloud_forest'
if (canopy > 0 && moisture > 0.3) return 'alpine_meadow'
return 'alpine_tundra'
}
if (temp > 0.55) {
if (moisture > 0.7 && canopy > 0.6) return 'tropical_rainforest'
if (canopy > 0) {
if (moisture > 0.4) return 'tropical_dry_forest'
if (moisture > 0.2) return 'savanna'
}
return 'desert'
}
if (temp > 0.25) {
if (canopy > 0.5) return 'temperate_forest'
if (moisture > 0.3 && canopy > 0) return 'temperate_grassland'
return 'chaparral'
}
if (temp > 0.1) {
if (canopy > 0.3) return 'boreal_forest'
if (canopy > 0) return 'tundra'
return 'polar_desert'
}
return 'polar_desert'
}
export function classifyBiome(tile: TileState): string {
if (tile.substrate_id === 'wetland' && tile.temperature > 0.55 && tile.is_coastal) {
return 'mangrove'
}
if (isWater(tile)) return classifyAquatic(tile)
if (tile.substrate_id === 'volcanic') return 'volcanic'
if (tile.has_cave) return 'subterranean'
return classifyLand(tile)
}
export function getEcologyFoodModifier(tile: TileState): number {
const mult: Record<number, number> = { 1: 0.5, 2: 1.0, 3: 1.5, 4: 2.0, 5: 2.5 }
let base = mult[tile.quality] ?? 1.0
if (!hasTag(tile.biome_id, 'is_water')) {
base *= 0.8 + 0.4 * tile.undergrowth
}
return base
}

View file

@ -1,8 +1,6 @@
export * from './types'
export * from './HexGrid'
export * from './ClimatePhysics.generated'
export * from './MapGenerator.generated'
export * from './EcologyPhysics.generated'
export * from './biomeClassifier'
export * from './biomeRegistry'
export * from './runner'
export * from './scenarios'

View file

@ -13,9 +13,8 @@ import type {
WindArrow,
} from './types'
import { solarByRow } from './HexGrid'
import { ClimatePhysics } from './ClimatePhysics.generated'
import { EcologyPhysics, classifyBiome } from './EcologyPhysics.generated'
import { generate as generateMap } from './MapGenerator.generated'
import { WasmClimatePhysics, WasmEcologyPhysics, WasmMapGenerator, WasmGrid } from '@magic-civ/physics-rs'
import { classifyBiome } from './biomeClassifier'
import { WORLD_SEED, DEFAULT_SCENARIO_TURNS, GRID_WIDTH, GRID_HEIGHT } from './configs'
// ---------------------------------------------------------------------------
@ -625,8 +624,16 @@ export function runScenarioSync(
const worldAge = config.worldAge
const totalTurns = worldAge + scenarioTurns
// Generate map from seed using the transpiled GDScript pipeline
const grid = generateMap(worldSeed, GRID_WIDTH, GRID_HEIGHT, terrainCache, params, 'continents')
const paramsJson = JSON.stringify(params)
const terrainJson = JSON.stringify(Object.fromEntries(terrainCache))
const specJson = '{}'
// Generate map from seed using WASM map generator
const mapGen = new WasmMapGenerator(paramsJson)
const wasmMapGrid = mapGen.generate(worldSeed, 'continents')
const grid: GridState = wasmMapGrid.toJSON() as GridState
wasmMapGrid.free()
mapGen.free()
// Derive is_coastal: land tiles adjacent to ocean/coast.
const waterBiomeIds = new Set(['ocean', 'coast', 'lake', 'inland_sea'])
@ -653,8 +660,8 @@ export function runScenarioSync(
tile.biome_id = classifyBiome(tile)
}
const physics = new ClimatePhysics(params, terrainCache)
const ecology = new EcologyPhysics()
const physics = new WasmClimatePhysics(paramsJson, terrainJson, specJson)
const ecology = config.abioticWorld ? null : new WasmEcologyPhysics()
const snapshots: GridSnapshot[] = []
const isVolcanicWinter = config.id === 'volcanic_winter'
@ -670,14 +677,16 @@ export function runScenarioSync(
grid.global_fish_stock = 1.0
grid.photosynthesisMultiplier = 1.0
const runEcology = !config.abioticWorld
// Phase 1: Geological history (worldAge turns) — climate + ecology, no scenario forcing.
// Phase 1: Geological history — run entirely in WASM
let wasmGrid = WasmGrid.fromJSON(grid)
for (let turn = 0; turn < worldAge; turn++) {
physics.processStep(grid, turn, worldSeed)
if (runEcology) ecology.processStep(grid)
stepAtmosphericChemistry(grid)
physics.processStep(wasmGrid, turn, worldSeed)
if (ecology) ecology.processStep(wasmGrid)
}
// Sync back to JS
const geoGrid = wasmGrid.toJSON() as GridState
Object.assign(grid, geoGrid)
wasmGrid.free()
// Phase 2: Apply scenario initMap overrides on the geologically mature world
config.initMap(grid)
@ -690,22 +699,38 @@ export function runScenarioSync(
}
// Phase 3: Scenario simulation — volcanic forcing, full recording
wasmGrid = WasmGrid.fromJSON(grid)
for (let turn = worldAge; turn < totalTurns; turn++) {
const scenarioTurn = turn - worldAge
if (isVolcanicWinter) applyVolcanicWinterForcing(grid)
if (isVolcanicWinter) {
const jsGrid = wasmGrid.toJSON() as GridState
applyVolcanicWinterForcing(jsGrid)
wasmGrid.free()
wasmGrid = WasmGrid.fromJSON(jsGrid)
}
const events = physics.processStep(grid, turn, worldSeed)
if (runEcology) ecology.processStep(grid)
physics.processStep(wasmGrid, turn, worldSeed)
if (ecology) ecology.processStep(wasmGrid)
// Sync to JS for snapshot encoding
const jsGrid = wasmGrid.toJSON() as GridState
Object.assign(grid, jsGrid)
stepAtmosphericChemistry(grid)
// Sync atmo changes back to WASM
wasmGrid.free()
wasmGrid = WasmGrid.fromJSON(grid)
const prev = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined
snapshots.push(encodeSnapshot(grid, scenarioTurn, events, prev))
snapshots.push(encodeSnapshot(grid, scenarioTurn, [], prev))
}
wasmGrid.free()
physics.free()
if (ecology) ecology.free()
return {
snapshots,
continuation: {
grid, physics, ecology, config,
grid, physics: null, ecology: null, config,
nextAbsoluteTurn: totalTurns,
worldSeed, isVolcanicWinter,
},
@ -722,30 +747,47 @@ export function extendSimulation(
): SimulationResult {
const { continuation } = prev
const { grid, config, nextAbsoluteTurn, worldSeed, isVolcanicWinter } = continuation
const physics = continuation.physics as ClimatePhysics
const ecology = continuation.ecology as EcologyPhysics
const worldAge = config.worldAge
const paramsJson = '{}'
const terrainJson = '{}'
const specJson = '{}'
const physics = new WasmClimatePhysics(paramsJson, terrainJson, specJson)
const ecology = config.abioticWorld ? null : new WasmEcologyPhysics()
const snapshots = [...prev.snapshots]
const endTurn = nextAbsoluteTurn + additionalTurns
const runEcology = !config.abioticWorld
let wasmGrid = WasmGrid.fromJSON(grid)
for (let turn = nextAbsoluteTurn; turn < endTurn; turn++) {
const scenarioTurn = turn - worldAge
if (isVolcanicWinter) applyVolcanicWinterForcing(grid)
if (isVolcanicWinter) {
const jsGrid = wasmGrid.toJSON() as GridState
applyVolcanicWinterForcing(jsGrid)
wasmGrid.free()
wasmGrid = WasmGrid.fromJSON(jsGrid)
}
const events = physics.processStep(grid, turn, worldSeed)
if (runEcology) ecology.processStep(grid)
physics.processStep(wasmGrid, turn, worldSeed)
if (ecology) ecology.processStep(wasmGrid)
const jsGrid = wasmGrid.toJSON() as GridState
Object.assign(grid, jsGrid)
stepAtmosphericChemistry(grid)
const prev = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined
snapshots.push(encodeSnapshot(grid, scenarioTurn, events, prev))
wasmGrid.free()
wasmGrid = WasmGrid.fromJSON(grid)
const prevSnap = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined
snapshots.push(encodeSnapshot(grid, scenarioTurn, [], prevSnap))
}
wasmGrid.free()
physics.free()
if (ecology) ecology.free()
return {
snapshots,
continuation: {
grid, physics, ecology, config,
grid, physics: null, ecology: null, config,
nextAbsoluteTurn: endTurn,
worldSeed, isVolcanicWinter,
},