diff --git a/packages/engine-ts/src/runner.ts b/packages/engine-ts/src/runner.ts index b6f668fe..d75495d9 100644 --- a/packages/engine-ts/src/runner.ts +++ b/packages/engine-ts/src/runner.ts @@ -8,8 +8,9 @@ import type { LeySchool, SimulationResult, } from './types' -import { GRID_WIDTH, GRID_HEIGHT } from './HexGrid' +import { GRID_WIDTH, GRID_HEIGHT, solarByRow } from './HexGrid' import { ClimatePhysics } from './ClimatePhysics.generated' +import { EcologyPhysics } from './EcologyPhysics.generated' import { generate as generateMap } from './MapGenerator.generated' import { WORLD_SEED, DEFAULT_SCENARIO_TURNS } from './configs' @@ -87,6 +88,7 @@ export function encodeSnapshot( grid: GridState, turn: number, events: EcologicalEvent[] = [], + terrainCache?: Map, ): GridSnapshot { const n = grid.tiles.length const texA = new Float32Array(n * 4) @@ -115,7 +117,7 @@ export function encodeSnapshot( texC[base + 3] = tile.habitat_suitability ?? 0.0 } - const stats = computeTurnStats(grid) + const stats = computeTurnStats(grid, terrainCache) return { texA, texB, texC, @@ -133,24 +135,61 @@ export function encodeSnapshot( const EMPTY_SCHOOL_RECORD: Record = { death: 0, life: 0, nature: 0, aether: 0, chaos: 0 } -export function computeTurnStats(grid: GridState): TurnStats { - const { tiles } = grid +export function computeTurnStats( + grid: GridState, + terrainCache?: Map, +): TurnStats { + const { tiles, width, height } = grid let tempSum = 0 let moistSum = 0 + let albedoSum = 0 + let solarSum = 0 + let landFloraSum = 0 + let landFaunaSum = 0 + let landQualitySum = 0 + let marineFloraSum = 0 + let marineFaunaSum = 0 + let waterQualitySum = 0 + let waterCount = 0 + let aerosolSum = 0 + let etSum = 0 let landCount = 0 const terrain_counts: Record = {} for (const tile of tiles) { terrain_counts[tile.biome_id] = (terrain_counts[tile.biome_id] ?? 0) + 1 const isWater = tile.biome_id === 'ocean' || tile.biome_id === 'coast' || - tile.biome_id === 'lake' || tile.biome_id === 'inland_sea' - if (!isWater) { + tile.biome_id === 'lake' || tile.biome_id === 'inland_sea' || + tile.biome_id === 'deep_ocean' || tile.biome_id === 'shallow_ocean' || + tile.biome_id === 'coral_reef' || tile.biome_id === 'estuary' || + tile.biome_id === 'pond' || tile.biome_id === 'river' || tile.biome_id === 'mangrove' + + const td = terrainCache?.get(tile.biome_id) + const albedo = (td as Record | undefined)?.['albedo'] ?? 0.3 + const solar = solarByRow(tile.row, height) + albedoSum += albedo + solarSum += solar * (1.0 - albedo) + + aerosolSum += (tile as Record).sulfate_aerosol ?? 0 + + if (isWater) { + waterCount++ + waterQualitySum += tile.quality ?? 1 + marineFloraSum += tile.reef_health ?? 0.0 + marineFaunaSum += tile.fish_stock ?? 0 + } else { landCount++ tempSum += tile.temperature moistSum += tile.moisture + landFloraSum += tile.canopy_cover ?? 0 + landFaunaSum += tile.habitat_suitability ?? 0 + landQualitySum += tile.quality ?? 1 + const et = (td as Record | undefined)?.['evapotranspiration'] ?? 0 + etSum += et } } + const n = tiles.length || 1 return { avg_temp: landCount > 0 ? tempSum / landCount : 0.5, avg_moisture: landCount > 0 ? moistSum / landCount : 0.5, @@ -158,7 +197,19 @@ export function computeTurnStats(grid: GridState): TurnStats { dominant_ley_school: '', ley_school_strengths: { ...EMPTY_SCHOOL_RECORD }, ley_land_coverage: { ...EMPTY_SCHOOL_RECORD }, + ocean_pct: tiles.length > 0 ? (tiles.length - landCount) / tiles.length : 0, ocean_dead_pct: grid.ocean_dead_fraction, + sea_level: grid.sea_level, + avg_albedo: albedoSum / n, + avg_solar: solarSum / n, + avg_land_flora: landCount > 0 ? landFloraSum / landCount : 0, + avg_land_fauna: landCount > 0 ? landFaunaSum / landCount : 0, + avg_marine_flora: waterCount > 0 ? marineFloraSum / waterCount : 1.0, + avg_marine_fauna: waterCount > 0 ? marineFaunaSum / waterCount : 0, + avg_land_quality: landCount > 0 ? landQualitySum / landCount : 1, + avg_water_quality: waterCount > 0 ? waterQualitySum / waterCount : 1, + avg_aerosol: aerosolSum / n, + avg_evapotranspiration: landCount > 0 ? etSum / landCount : 0, terrain_counts, } } @@ -233,13 +284,15 @@ export function runScenarioSync( // Generate map from seed using the transpiled GDScript pipeline const grid = generateMap(worldSeed, GRID_WIDTH, GRID_HEIGHT, terrainCache, params, 'continents') const physics = new ClimatePhysics(params, terrainCache) + const ecology = new EcologyPhysics() const snapshots: GridSnapshot[] = [] const isVolcanicWinter = config.id === 'volcanic_winter' - // Phase 1: Geological history (worldAge turns) — pure physics + ecology, no scenario forcing. + // Phase 1: Geological history (worldAge turns) — climate + ecology, no scenario forcing. for (let turn = 0; turn < worldAge; turn++) { physics.processStep(grid, turn, worldSeed) + ecology.processStep(grid) } // Phase 2: Apply scenario initMap overrides on the geologically mature world @@ -252,13 +305,14 @@ export function runScenarioSync( if (isVolcanicWinter) applyVolcanicWinterForcing(grid) const events = physics.processStep(grid, turn, worldSeed) - snapshots.push(encodeSnapshot(grid, scenarioTurn, events)) + ecology.processStep(grid) + snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache)) } return { snapshots, continuation: { - grid, physics, config, + grid, physics, ecology, config, nextAbsoluteTurn: totalTurns, worldSeed, isVolcanicWinter, }, @@ -276,6 +330,7 @@ export function extendSimulation( 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 snapshots = [...prev.snapshots] @@ -287,13 +342,14 @@ export function extendSimulation( if (isVolcanicWinter) applyVolcanicWinterForcing(grid) const events = physics.processStep(grid, turn, worldSeed) - snapshots.push(encodeSnapshot(grid, scenarioTurn, events)) + ecology.processStep(grid) + snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache)) } return { snapshots, continuation: { - grid, physics, config, + grid, physics, ecology, config, nextAbsoluteTurn: endTurn, worldSeed, isVolcanicWinter, }, diff --git a/packages/engine-ts/src/types.ts b/packages/engine-ts/src/types.ts index 565ea22a..5e1f75b5 100644 --- a/packages/engine-ts/src/types.ts +++ b/packages/engine-ts/src/types.ts @@ -62,7 +62,19 @@ export interface TurnStats { dominant_ley_school: LeySchool | '' ley_school_strengths: Record ley_land_coverage: Record // fraction of land tiles affiliated with each school - ocean_dead_pct: number + ocean_pct: number // fraction of all tiles that are water + ocean_dead_pct: number // fraction of coast reefs that are dead + sea_level: number // current sea level elevation + avg_albedo: number // global average albedo (0=absorbs all, 1=reflects all) + avg_solar: number // global average solar input after albedo + avg_land_flora: number // average canopy_cover across land tiles + avg_land_fauna: number // average habitat_suitability across land tiles + avg_marine_flora: number // average reef_health across coast tiles + avg_marine_fauna: number // average fish_stock across coast tiles + avg_land_quality: number // average quality across land tiles (1-5) + avg_water_quality: number // average quality across water tiles (1-5) + avg_aerosol: number // global average sulfate aerosol opacity + avg_evapotranspiration: number // average ET contribution across land tiles terrain_counts: Record } @@ -130,9 +142,10 @@ export interface ContinuationState { nextAbsoluteTurn: number worldSeed: number isVolcanicWinter: boolean - // ClimatePhysics is stored as `unknown` here to avoid - // importing the class type into the shared types file. The runner casts it. + // ClimatePhysics and EcologyPhysics are stored as `unknown` here to avoid + // importing the class types into the shared types file. The runner casts them. physics: unknown + ecology: unknown } /** Result of running or extending a simulation. */