diff --git a/guide/engine/src/components/climate-sim/StatsDashboard.tsx b/guide/engine/src/components/climate-sim/StatsDashboard.tsx index 2419508b..87c9797a 100644 --- a/guide/engine/src/components/climate-sim/StatsDashboard.tsx +++ b/guide/engine/src/components/climate-sim/StatsDashboard.tsx @@ -56,116 +56,67 @@ const PRIMARY_METRICS: MetricDef[] = [ { key: 'temp', label: 'Temp', tooltip: 'Average land temperature (0=frozen, 1=scorching). Driven by solar input, albedo, and orbital cycles.', color: '#E85D3A', getValue: (s) => s.avg_temp, - formatValue: (v) => v.toFixed(2), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(2), + formatValue: fmt2, + formatDelta: fmtDelta2, phaseBands: TEMP_PHASE_BANDS, }, { key: 'moisture', label: 'Moisture', tooltip: 'Average land moisture (0=bone dry, 1=saturated). Driven by ocean evaporation, wind transport, and precipitation.', color: '#26A69A', getValue: (s) => s.avg_moisture, - formatValue: (v) => v.toFixed(2), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(2), + formatValue: fmt2, + formatDelta: fmtDelta2, }, + m('sea_level'), ] -// Left column: LAND metrics -const COMPACT_LEFT: MetricDef[] = [ - { - key: 'land_flora', label: 'Flora', tooltip: 'Average canopy cover across land tiles (0=barren, 1=full canopy). Forests and jungles are high; deserts and tundra near zero.', color: '#66BB6A', - getValue: (s) => s.avg_land_flora, - formatValue: (v) => v.toFixed(3), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3), - }, - { - key: 'land_fauna', label: 'Fauna', tooltip: 'Average habitat suitability across land tiles (0=inhospitable, 1=thriving). Combines flora density, moisture, and temperature.', color: '#8D6E63', - getValue: (s) => s.avg_land_fauna, - formatValue: (v) => v.toFixed(3), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3), - }, - { - key: 'land_quality', label: 'Quality', tooltip: 'Average tile quality across land (1-5). Rises when climate matches ideal biome, falls when it shifts away. Higher = better yields.', color: '#FFD54F', - getValue: (s) => s.avg_land_quality, - formatValue: (v) => v.toFixed(2), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(2), - }, - { - key: 'evapotrans', label: 'ET', tooltip: 'Average evapotranspiration across land tiles. Moisture recycled by vegetation per turn. Forests contribute most; deserts are negative.', color: '#80DEEA', - getValue: (s) => s.avg_evapotranspiration, - formatValue: (v) => v.toFixed(4), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(4), - }, +// ── Metric catalog (single source of truth) ───────────────────────────────── +// Each metric defined ONCE. Dashboard layouts select by key. + +const fmt3 = (v: number) => v.toFixed(3) +const fmt2 = (v: number) => v.toFixed(2) +const fmt4 = (v: number) => v.toFixed(4) +const fmtDelta3 = (d: number) => (d >= 0 ? '+' : '') + d.toFixed(3) +const fmtDelta2 = (d: number) => (d >= 0 ? '+' : '') + d.toFixed(2) +const fmtDelta4 = (d: number) => (d >= 0 ? '+' : '') + d.toFixed(4) + +const METRIC_CATALOG: Record = { + // -- Climate / atmosphere -- + sea_level: { key: 'sea_level', label: 'Sea Lvl', tooltip: 'Elevation threshold for water. Rises with warming, falls with cooling.', color: '#5C6BC0', getValue: (s) => s.sea_level, formatValue: fmt3, formatDelta: fmtDelta3 }, + et: { key: 'evapotrans', label: 'ET', tooltip: 'Average evapotranspiration across land. Moisture recycled by vegetation per turn.', color: '#80DEEA', getValue: (s) => s.avg_evapotranspiration, formatValue: fmt4, formatDelta: fmtDelta4 }, + albedo: { key: 'albedo', label: 'Albedo', tooltip: 'Global average surface reflectivity (0=absorbs all, 1=reflects all). Ice/snow raise it, forests/water lower it.', color: '#BDBDBD', getValue: (s) => s.avg_albedo, formatValue: fmt3, formatDelta: fmtDelta3 }, + aerosol: { key: 'aerosol', label: 'Aerosol', tooltip: 'Global average sulfate aerosol opacity. Volcanic eruptions inject aerosols that cool and dry the atmosphere.', color: '#90A4AE', getValue: (s) => s.avg_aerosol, formatValue: fmt4, formatDelta: fmtDelta4 }, + // -- Land ecology -- + land_canopy: { key: 'land_canopy', label: 'Canopy', tooltip: 'Average tree canopy cover across land (0=barren, 1=full canopy). Drives succession and shading.', color: '#2E7D32', getValue: (s) => s.avg_land_canopy, formatValue: fmt3, formatDelta: fmtDelta3 }, + land_under: { key: 'land_undergrowth', label: 'Undergrowth', tooltip: 'Average ground vegetation across land (0=bare, 1=dense). Drives food yield and habitat quality.', color: '#66BB6A', getValue: (s) => s.avg_land_undergrowth, formatValue: fmt3, formatDelta: fmtDelta3 }, + land_fungi: { key: 'land_fungi', label: 'Fungi', tooltip: 'Average mycorrhizal network across land (0=none, 1=dense). Accelerates forest regrowth, boosts ecosystem resilience.', color: '#8D6E63', getValue: (s) => s.avg_land_fungi, formatValue: fmt3, formatDelta: fmtDelta3 }, + land_habitat: { key: 'land_habitat', label: 'Habitat', tooltip: 'Average habitat suitability across land (0=inhospitable, 1=thriving). Combines flora density, moisture, temperature.', color: '#A1887F', getValue: (s) => s.avg_land_habitat, formatValue: fmt3, formatDelta: fmtDelta3 }, + land_quality: { key: 'land_quality', label: 'Quality', tooltip: 'Average tile quality across land (1=Prolific, 5=Epic). Ecology composite of flora health, fauna diversity, biome stability.', color: '#FFD54F', getValue: (s) => s.avg_land_quality, formatValue: fmt2, formatDelta: fmtDelta2 }, + // -- Water ecology -- + water_reef: { key: 'water_reef', label: 'Reef', tooltip: 'Average reef health across water tiles (0=dead, 1=pristine). Bleaching from high temps (>0.75). Dead reefs halve fish capacity.', color: '#29B6F6', getValue: (s) => s.avg_water_reef, formatValue: fmt3, formatDelta: fmtDelta3 }, + water_fish: { key: 'water_fish', label: 'Fish', tooltip: 'Average fish stock across water tiles (0=empty, 100+=abundant). Logistic reproduction, temperature-scaled.', color: '#26C6DA', getValue: (s) => s.avg_water_fish, formatValue: fmt2, formatDelta: fmtDelta2 }, + water_quality:{ key: 'water_quality', label: 'Quality', tooltip: 'Average tile quality across water tiles (1=Prolific, 5=Epic). Reflects marine ecosystem health.', color: '#42A5F5', getValue: (s) => s.avg_water_quality, formatValue: fmt2, formatDelta: fmtDelta2 }, +} + +const m = (key: string): MetricDef => METRIC_CATALOG[key] + +// Life mode: LAND column (fauna quality first, then flora quality, then details) +const LIFE_LEFT: MetricDef[] = [ + m('land_habitat'), m('land_quality'), m('land_canopy'), m('land_under'), m('land_fungi'), ] -// Right column: WATER + atmosphere metrics -const COMPACT_RIGHT: MetricDef[] = [ - { - key: 'marine_flora', label: 'Flora', tooltip: 'Average reef health across coastal tiles (1=healthy coral, 0=dead). Bleaching from high temps (>0.75) destroys reefs. Dead reefs reduce evaporation.', color: '#29B6F6', - getValue: (s) => s.avg_marine_flora, - formatValue: (v) => v.toFixed(3), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3), - }, - { - key: 'marine_fauna', label: 'Fauna', tooltip: 'Average fish stock across coastal tiles (0=depleted, 1=abundant). Depends on reef health and water temperature.', color: '#26C6DA', - getValue: (s) => s.avg_marine_fauna, - formatValue: (v) => v.toFixed(3), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3), - }, - { - key: 'water_quality', label: 'Quality', tooltip: 'Average tile quality across water tiles (1-5). Reflects ocean and coastal ecosystem health.', color: '#42A5F5', - getValue: (s) => s.avg_water_quality, - formatValue: (v) => v.toFixed(2), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(2), - }, - { - key: 'sea_level', label: 'Sea Lvl', tooltip: 'Elevation threshold for water. Rises with warming (thermal expansion + ice melt), falls with cooling. Land below this floods.', color: '#5C6BC0', - getValue: (s) => s.sea_level, - formatValue: (v) => v.toFixed(3), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3), - }, +// Life mode: WATER column (fauna quality first, then flora quality, then details) +const LIFE_RIGHT: MetricDef[] = [ + m('water_fish'), m('water_quality'), m('water_reef'), ] -// Environment mode right column -const ENV_RIGHT_METRICS: MetricDef[] = [ - { - key: 'sea_level', label: 'Sea Lvl', tooltip: 'Elevation threshold for water. Rises with warming (thermal expansion + ice melt), falls with cooling. Land below this floods.', color: '#5C6BC0', - getValue: (s) => s.sea_level, - formatValue: (v) => v.toFixed(3), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3), - }, - { - key: 'evapotrans', label: 'ET', tooltip: 'Average evapotranspiration across land tiles. Moisture recycled by vegetation per turn.', color: '#80DEEA', - getValue: (s) => s.avg_evapotranspiration, - formatValue: (v) => v.toFixed(4), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(4), - }, - { - key: 'land_quality', label: 'Land Qlty', tooltip: 'Average tile quality across land (1-5). Rises when climate matches ideal biome.', color: '#FFD54F', - getValue: (s) => s.avg_land_quality, - formatValue: (v) => v.toFixed(2), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(2), - }, +// Environment mode: left column (energy budget) +const ENV_LEFT: MetricDef[] = [ + m('albedo'), m('et'), m('aerosol'), ] -// Environment mode left column: atmosphere -const ATMOSPHERE_METRICS: MetricDef[] = [ - { - key: 'solar', label: 'Solar', tooltip: 'Average absorbed solar energy after albedo reflection. The net heat input driving temperature.', color: '#FFA726', - getValue: (s) => s.avg_solar, - formatValue: (v) => v.toFixed(3), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3), - }, - { - key: 'albedo', label: 'Albedo', tooltip: 'Global average surface reflectivity (0=absorbs all, 1=reflects all). Ice/snow raise it, forests/water lower it.', color: '#BDBDBD', - getValue: (s) => s.avg_albedo, - formatValue: (v) => v.toFixed(3), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(3), - }, - { - key: 'aerosol', label: 'Aerosol', tooltip: 'Sulfate aerosol opacity from volcanic/impact events. Blocks sunlight, causing cooling and drying. Decays over ~20 turns.', color: '#AB47BC', - getValue: (s) => s.avg_aerosol, - formatValue: (v) => v.toFixed(4), - formatDelta: (d) => (d >= 0 ? '+' : '') + d.toFixed(4), - }, +// Environment mode: right column (land/water state) +const ENV_RIGHT: MetricDef[] = [ + m('land_quality'), m('water_quality'), ] // ── terrain groups ───────────────────────────────────────────────────────── @@ -178,15 +129,17 @@ interface TerrainGroup { } const TERRAIN_GROUPS: TerrainGroup[] = [ - { label: 'Water', abbr: 'Wtr', ids: ['ocean', 'coast', 'lake', 'inland_sea'], color: 'rgb(61,120,209)' }, - { label: 'Frozen', abbr: 'Frz', ids: ['ice', 'snow'], color: 'rgb(224,240,255)' }, + { label: 'Ocean', abbr: 'Ocn', ids: ['ocean', 'coast', 'deep_ocean', 'shallow_ocean', 'coral_reef', 'estuary', 'mangrove'], color: 'rgb(61,120,209)' }, + { label: 'Fresh', abbr: 'Frs', ids: ['lake', 'pond', 'river', 'inland_sea'], color: 'rgb(100,160,230)' }, + { label: 'Ice', abbr: 'Ice', ids: ['permanent_ice', 'ice', 'snow', 'alpine_tundra', 'polar_desert'], color: 'rgb(224,240,255)' }, { label: 'Tundra', abbr: 'Tnd', ids: ['tundra'], color: 'rgb(184,194,166)' }, - { label: 'Arid', abbr: 'Ard', ids: ['desert'], color: 'rgb(222,199,128)' }, - { label: 'Grassland', abbr: 'Grs', ids: ['plains', 'grassland'], color: 'rgb(141,197,112)' }, - { label: 'Forest', abbr: 'For', ids: ['forest', 'boreal_forest', 'jungle', 'enchanted_forest'], color: 'rgb(51,140,64)' }, + { label: 'Arid', abbr: 'Ard', ids: ['desert', 'chaparral'], color: 'rgb(222,199,128)' }, + { label: 'Grass', abbr: 'Grs', ids: ['plains', 'grassland', 'temperate_grassland', 'savanna', 'alpine_meadow'], color: 'rgb(141,197,112)' }, + { label: 'Forest', abbr: 'For', ids: ['forest', 'temperate_forest', 'boreal_forest', 'tropical_rainforest', 'tropical_dry_forest', 'montane_forest', 'cloud_forest', 'jungle', 'enchanted_forest'], color: 'rgb(51,140,64)' }, { label: 'Rough', abbr: 'Rgh', ids: ['hills', 'mountains'], color: 'rgb(158,153,148)' }, - { label: 'Wetland', abbr: 'Wet', ids: ['swamp'], color: 'rgb(61,79,36)' }, + { label: 'Wetland', abbr: 'Wet', ids: ['swamp', 'bog'], color: 'rgb(61,79,36)' }, { label: 'Volcanic', abbr: 'Vol', ids: ['volcano'], color: 'rgb(191,51,20)' }, + { label: 'Cave', abbr: 'Cav', ids: ['subterranean'], color: 'rgb(90,70,60)' }, ] // ── props ────────────────────────────────────────────────────────────────── @@ -236,7 +189,7 @@ export function StatsDashboard({ stats, currentTurn, onScrub, category = 'enviro Land - {COMPACT_LEFT.map((def) => { + {LIFE_LEFT.map((def) => { const val = currentStats ? def.getValue(currentStats) : 0 return ( @@ -249,7 +202,7 @@ export function StatsDashboard({ stats, currentTurn, onScrub, category = 'enviro Water - {COMPACT_RIGHT.map((def) => { + {LIFE_RIGHT.map((def) => { const val = currentStats ? def.getValue(currentStats) : 0 return ( @@ -265,7 +218,7 @@ export function StatsDashboard({ stats, currentTurn, onScrub, category = 'enviro /* Environment mode: atmosphere + sea level metrics */ - {ATMOSPHERE_METRICS.map((def) => { + {ENV_LEFT.map((def) => { const val = currentStats ? def.getValue(currentStats) : 0 return ( @@ -277,7 +230,7 @@ export function StatsDashboard({ stats, currentTurn, onScrub, category = 'enviro })} - {ENV_RIGHT_METRICS.map((def) => { + {ENV_RIGHT.map((def) => { const val = currentStats ? def.getValue(currentStats) : 0 return ( @@ -406,7 +359,7 @@ function TerrainDistChart({ stats, currentTurn, onScrub }: StatsDashboardProps): // Match total height of primary + compact sections // Height matches the tallest possible content (life mode with headers) - const maxCompactRows = Math.max(COMPACT_LEFT.length, COMPACT_RIGHT.length) + 1 + const maxCompactRows = Math.max(LIFE_LEFT.length, LIFE_RIGHT.length) + 1 const chartH = PRIMARY_METRICS.length * (PRIMARY_H + 3) + 6 + maxCompactRows * (COMPACT_H + 3) diff --git a/packages/engine-ts/src/EcologyPhysics.generated.ts b/packages/engine-ts/src/EcologyPhysics.generated.ts index a5057ad7..8d70e245 100644 --- a/packages/engine-ts/src/EcologyPhysics.generated.ts +++ b/packages/engine-ts/src/EcologyPhysics.generated.ts @@ -390,14 +390,19 @@ function updateHabitatSuitability( function updateFishStock(tiles: TileState[], w: number, h: number): void { for (let i = 0; i < tiles.length; i++) { const tile = tiles[i] - if (!isWater(tile) || (tile.fish_stock ?? 0) <= 0) continue + if (!isWater(tile)) continue let tempMult = 0.5 // polar if (tile.temperature > 0.55) tempMult = 1.0 // tropical else if (tile.temperature > 0.25) tempMult = 0.8 // temperate let cap = 100.0 if (tile.reef_health > 0.5) cap *= 1.5 else if (tile.reef_health < 0.1) cap *= 0.5 - const stock = tile.fish_stock ?? 0 + let stock = tile.fish_stock ?? 0 + // Seed fish on water tiles that have none yet (spontaneous colonization) + if (stock <= 0) { + stock = Math.round(cap * 0.1 * tempMult) // start at 10% of capacity + if (stock <= 0) stock = 1 + } const growth = 0.05 * tempMult * stock * (1.0 - stock / cap) tile.fish_stock = Math.max(0, Math.min(Math.round(stock + growth), cap)) } diff --git a/packages/engine-ts/src/MapGenerator.generated.ts b/packages/engine-ts/src/MapGenerator.generated.ts index 9c5bda93..79dd9f6a 100644 --- a/packages/engine-ts/src/MapGenerator.generated.ts +++ b/packages/engine-ts/src/MapGenerator.generated.ts @@ -264,6 +264,21 @@ class GenMap { wonder_anchor_schools: [], wonder_tier: 0, river_source_type: gt?.river_source_type || undefined, + // Ecology fields (initialized to zero, populated by EcologyPhysics) + canopy_cover: 0.0, + undergrowth: 0.0, + fungi_network: 0.0, + drought_counter: 0, + succession_progress: 0, + regrowth_stage: -1, + regrowth_turns: 0, + habitat_suitability: 0.0, + habitat_low_turns: 0, + landmark_name: '', + substrate_id: '', + water_body_id: -1, + depth_from_coast: -1, + fish_stock: 0, } } } diff --git a/packages/engine-ts/src/runner.ts b/packages/engine-ts/src/runner.ts index d75495d9..0d031146 100644 --- a/packages/engine-ts/src/runner.ts +++ b/packages/engine-ts/src/runner.ts @@ -144,11 +144,13 @@ export function computeTurnStats( let moistSum = 0 let albedoSum = 0 let solarSum = 0 - let landFloraSum = 0 - let landFaunaSum = 0 + let landCanopySum = 0 + let landUndergrowthSum = 0 + let landFungiSum = 0 + let landHabitatSum = 0 let landQualitySum = 0 - let marineFloraSum = 0 - let marineFaunaSum = 0 + let waterReefSum = 0 + let waterFishSum = 0 let waterQualitySum = 0 let waterCount = 0 let aerosolSum = 0 @@ -175,14 +177,16 @@ export function computeTurnStats( if (isWater) { waterCount++ waterQualitySum += tile.quality ?? 1 - marineFloraSum += tile.reef_health ?? 0.0 - marineFaunaSum += tile.fish_stock ?? 0 + waterReefSum += tile.reef_health ?? 0.0 + waterFishSum += tile.fish_stock ?? 0 } else { landCount++ tempSum += tile.temperature moistSum += tile.moisture - landFloraSum += tile.canopy_cover ?? 0 - landFaunaSum += tile.habitat_suitability ?? 0 + landCanopySum += tile.canopy_cover ?? 0 + landUndergrowthSum += tile.undergrowth ?? 0 + landFungiSum += tile.fungi_network ?? 0 + landHabitatSum += tile.habitat_suitability ?? 0 landQualitySum += tile.quality ?? 1 const et = (td as Record | undefined)?.['evapotranspiration'] ?? 0 etSum += et @@ -202,10 +206,12 @@ export function computeTurnStats( 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_canopy: landCount > 0 ? landCanopySum / landCount : 0, + avg_land_undergrowth: landCount > 0 ? landUndergrowthSum / landCount : 0, + avg_land_fungi: landCount > 0 ? landFungiSum / landCount : 0, + avg_land_habitat: landCount > 0 ? landHabitatSum / landCount : 0, + avg_water_reef: waterCount > 0 ? waterReefSum / waterCount : 0, + avg_water_fish: waterCount > 0 ? waterFishSum / waterCount : 0, avg_land_quality: landCount > 0 ? landQualitySum / landCount : 1, avg_water_quality: waterCount > 0 ? waterQualitySum / waterCount : 1, avg_aerosol: aerosolSum / n, diff --git a/packages/engine-ts/src/types.ts b/packages/engine-ts/src/types.ts index 5e1f75b5..071719f3 100644 --- a/packages/engine-ts/src/types.ts +++ b/packages/engine-ts/src/types.ts @@ -67,10 +67,12 @@ export interface TurnStats { 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_canopy: number // average canopy_cover across land tiles + avg_land_undergrowth: number // average undergrowth across land tiles + avg_land_fungi: number // average fungi_network across land tiles + avg_land_habitat: number // average habitat_suitability across land tiles + avg_water_reef: number // average reef_health across water tiles + avg_water_fish: number // average fish_stock across water 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 diff --git a/tools/sprite-generation/sprites.db b/tools/sprite-generation/sprites.db index c1d9a2d6..3bf8cc19 100644 Binary files a/tools/sprite-generation/sprites.db and b/tools/sprite-generation/sprites.db differ diff --git a/tools/sprite-generation/sprites.db-shm b/tools/sprite-generation/sprites.db-shm index 2f60f931..e22f21ef 100644 Binary files a/tools/sprite-generation/sprites.db-shm and b/tools/sprite-generation/sprites.db-shm differ diff --git a/tools/sprite-generation/sprites.db-wal b/tools/sprite-generation/sprites.db-wal index e2b16953..15ccc841 100644 Binary files a/tools/sprite-generation/sprites.db-wal and b/tools/sprite-generation/sprites.db-wal differ