diff --git a/guide/age-of-four/src/simulation/__tests__/terrain-chart-grouping.test.ts b/guide/age-of-four/src/simulation/__tests__/terrain-chart-grouping.test.ts new file mode 100644 index 00000000..e73496fe --- /dev/null +++ b/guide/age-of-four/src/simulation/__tests__/terrain-chart-grouping.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest' + +/** + * Terrain chart grouping tests. + * Verifies that TERRAIN_GROUPS covers all biome_ids produced by the classifier, + * and that the stacked proportions sum to 1.0 for any tile distribution. + */ + +// Mirror of TERRAIN_GROUPS from StatsDashboard.tsx — single source of truth +const TERRAIN_GROUPS = [ + { abbr: 'Ocn', ids: ['ocean', 'coast', 'deep_ocean', 'shallow_ocean', 'coral_reef', 'estuary', 'mangrove'] }, + { abbr: 'Frs', ids: ['lake', 'pond', 'river', 'inland_sea'] }, + { abbr: 'Ice', ids: ['permanent_ice', 'ice', 'snow', 'alpine_tundra', 'polar_desert'] }, + { abbr: 'Tnd', ids: ['tundra'] }, + { abbr: 'Ard', ids: ['desert', 'chaparral'] }, + { abbr: 'Grs', ids: ['plains', 'grassland', 'temperate_grassland', 'savanna', 'alpine_meadow'] }, + { abbr: 'For', ids: ['forest', 'temperate_forest', 'boreal_forest', 'tropical_rainforest', 'tropical_dry_forest', 'montane_forest', 'cloud_forest', 'jungle', 'enchanted_forest'] }, + { abbr: 'Rgh', ids: ['hills', 'mountains'] }, + { abbr: 'Wet', ids: ['swamp', 'bog'] }, + { abbr: 'Vol', ids: ['volcano'] }, + { abbr: 'Cav', ids: ['subterranean'] }, +] + +// All biome_ids the TS classifier can produce (from EcologyPhysics.generated.ts classifyBiome) +const TS_CLASSIFIER_BIOMES = [ + 'ocean', 'coast', 'lake', // water (passthrough from MapGenerator) + 'swamp', 'tundra', 'boreal_forest', 'grassland', + 'temperate_forest', 'desert', 'tropical_rainforest', +] + +// Full biome set from GDScript classifier (M2b) +// Includes 26 ecology biomes + legacy terrain names that MapGenerator may still emit +const FULL_BIOMES = [ + 'deep_ocean', 'shallow_ocean', 'coral_reef', 'estuary', 'lake', 'pond', 'river', 'mangrove', + 'tropical_rainforest', 'tropical_dry_forest', 'savanna', 'desert', + 'temperate_forest', 'temperate_grassland', 'chaparral', 'swamp', 'bog', + 'boreal_forest', 'tundra', 'polar_desert', + 'montane_forest', 'cloud_forest', 'alpine_meadow', 'alpine_tundra', 'permanent_ice', + 'subterranean', + // Legacy terrain names still emitted by MapGenerator before BiomeClassifier runs + 'ocean', 'coast', 'hills', 'mountains', 'volcano', 'plains', 'grassland', 'forest', 'jungle', +] + +function groupCounts(terrainCounts: Record): number[] { + return TERRAIN_GROUPS.map((g) => + g.ids.reduce((sum, id) => sum + (terrainCounts[id] ?? 0), 0), + ) +} + +function proportions(counts: number[]): number[] { + const total = counts.reduce((a, b) => a + b, 0) || 1 + return counts.map((c) => c / total) +} + +describe('Terrain chart grouping', () => { + it('every TS classifier biome maps to exactly one group', () => { + for (const biome of TS_CLASSIFIER_BIOMES) { + const matchingGroups = TERRAIN_GROUPS.filter((g) => g.ids.includes(biome)) + expect(matchingGroups.length, `biome "${biome}" should be in exactly 1 group`).toBe(1) + } + }) + + it('every full biome (M2b) maps to exactly one group', () => { + for (const biome of FULL_BIOMES) { + const matchingGroups = TERRAIN_GROUPS.filter((g) => g.ids.includes(biome)) + expect(matchingGroups.length, `biome "${biome}" should be in exactly 1 group`).toBe(1) + } + }) + + it('no biome appears in multiple groups', () => { + const allIds = TERRAIN_GROUPS.flatMap((g) => g.ids) + const seen = new Set() + for (const id of allIds) { + expect(seen.has(id), `biome "${id}" appears in multiple groups`).toBe(false) + seen.add(id) + } + }) + + it('proportions sum to 1.0 for a typical tile distribution', () => { + const terrainCounts: Record = { + ocean: 400, coast: 50, tundra: 80, grassland: 120, + temperate_forest: 60, desert: 40, boreal_forest: 30, + swamp: 20, tropical_rainforest: 10, lake: 15, + } + const counts = groupCounts(terrainCounts) + const props = proportions(counts) + const sum = props.reduce((a, b) => a + b, 0) + expect(sum).toBeCloseTo(1.0, 5) + }) + + it('proportions sum to 1.0 with all-water world', () => { + const terrainCounts: Record = { ocean: 1000 } + const props = proportions(groupCounts(terrainCounts)) + expect(props.reduce((a, b) => a + b, 0)).toBeCloseTo(1.0, 5) + expect(props[0]).toBeCloseTo(1.0, 5) // Ocean group is index 0 + }) + + it('unknown biome_ids are silently excluded (reduce total)', () => { + const terrainCounts: Record = { + ocean: 500, unknown_biome: 100, grassland: 400, + } + const counts = groupCounts(terrainCounts) + const total = counts.reduce((a, b) => a + b, 0) + // unknown_biome (100) not counted + expect(total).toBe(900) + }) + + it('empty terrain_counts produces all-zero proportions', () => { + const props = proportions(groupCounts({})) + for (const p of props) { + expect(p).toBe(0) + } + }) + + it('M2b biomes produce non-zero counts for all expected groups', () => { + // Simulate a world with at least one tile of each M2b biome + const terrainCounts: Record = {} + for (const biome of FULL_BIOMES) { + terrainCounts[biome] = 10 + } + const counts = groupCounts(terrainCounts) + // Every group except maybe Cave (subterranean may not always appear) + for (let i = 0; i < counts.length; i++) { + expect(counts[i], `group "${TERRAIN_GROUPS[i].abbr}" should have count > 0`).toBeGreaterThan(0) + } + }) +}) diff --git a/guide/engine/src/components/climate-sim/StatsDashboard.tsx b/guide/engine/src/components/climate-sim/StatsDashboard.tsx index 7a28cb99..13c2748d 100644 --- a/guide/engine/src/components/climate-sim/StatsDashboard.tsx +++ b/guide/engine/src/components/climate-sim/StatsDashboard.tsx @@ -149,7 +149,8 @@ interface TerrainGroup { color: string } -const TERRAIN_GROUPS: TerrainGroup[] = [ +// Biome groups (Life mode chart) — groups 26 biome_ids into visual categories +const BIOME_GROUPS: TerrainGroup[] = [ { 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)' }, @@ -163,6 +164,20 @@ const TERRAIN_GROUPS: TerrainGroup[] = [ { label: 'Cave', abbr: 'Cav', ids: ['subterranean'], color: 'rgb(90,70,60)' }, ] +// Substrate groups (Environment mode chart) — geological layer +const SUBSTRATE_GROUPS: TerrainGroup[] = [ + { label: 'Deep Water', abbr: 'Dep', ids: ['deep_water'], color: 'rgb(30,70,160)' }, + { label: 'Shallows', abbr: 'Shl', ids: ['shallow_water'], color: 'rgb(61,120,209)' }, + { label: 'Lake Bed', abbr: 'Lak', ids: ['lake_bed'], color: 'rgb(100,160,230)' }, + { label: 'Lowland', abbr: 'Low', ids: ['lowland'], color: 'rgb(141,197,112)' }, + { label: 'Wetland', abbr: 'Wet', ids: ['wetland'], color: 'rgb(61,79,36)' }, + { label: 'Midland', abbr: 'Mid', ids: ['midland'], color: 'rgb(180,180,120)' }, + { label: 'Highland', abbr: 'Hgh', ids: ['highland'], color: 'rgb(158,153,148)' }, + { label: 'Mountain', abbr: 'Mtn', ids: ['mountain'], color: 'rgb(120,115,110)' }, + { label: 'Peak', abbr: 'Pk', ids: ['peak'], color: 'rgb(220,225,230)' }, + { label: 'Volcanic', abbr: 'Vol', ids: ['volcanic'], color: 'rgb(191,51,20)' }, +] + // ── props ────────────────────────────────────────────────────────────────── type SimCategory = 'environment' | 'life' @@ -366,13 +381,18 @@ function SparklineCanvas({ stats, currentTurn, def, onScrub, height }: Sparkline // ── terrain distribution chart ───────────────────────────────────────────── -function TerrainDistChart({ stats, currentTurn, onScrub }: StatsDashboardProps): ReactElement { +function TerrainDistChart({ stats, currentTurn, onScrub, category }: StatsDashboardProps): ReactElement { const canvasRef = useRef(null) const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio, 2) : 1 + const groups = category === 'life' ? BIOME_GROUPS : SUBSTRATE_GROUPS + const countsKey = category === 'life' ? 'terrain_counts' : 'substrate_counts' + const chartLabel = category === 'life' ? 'Biomes' : 'Geology' + const groupedData = stats.map((s) => { - const counts = TERRAIN_GROUPS.map((g) => - g.ids.reduce((sum, id) => sum + (s.terrain_counts[id] ?? 0), 0), + const src = (s as Record)[countsKey] as Record | undefined ?? {} + const counts = groups.map((g) => + g.ids.reduce((sum, id) => sum + (src[id] ?? 0), 0), ) const total = counts.reduce((a, b) => a + b, 0) || 1 return counts.map((c) => c / total) @@ -412,12 +432,12 @@ function TerrainDistChart({ stats, currentTurn, onScrub }: StatsDashboardProps): const x = t * colW let yBottom = height - for (let g = 0; g < TERRAIN_GROUPS.length; g++) { + for (let g = 0; g < groups.length; g++) { const pct = proportions[g] if (pct < 0.001) continue const barH = pct * height yBottom -= barH - ctx.fillStyle = TERRAIN_GROUPS[g].color + ctx.fillStyle = groups[g].color ctx.fillRect(x, yBottom, Math.max(colW, 1), barH) } } @@ -429,7 +449,7 @@ function TerrainDistChart({ stats, currentTurn, onScrub }: StatsDashboardProps): ctx.strokeStyle = 'rgba(255,255,255,0.4)' ctx.lineWidth = 1 ctx.stroke() - }, [stats, currentTurn, groupedData, chartH, dpr]) + }, [stats, currentTurn, groupedData, chartH, dpr, groups]) const handleClick = useCallback((e: React.MouseEvent) => { if (stats.length === 0) return @@ -441,14 +461,14 @@ function TerrainDistChart({ stats, currentTurn, onScrub }: StatsDashboardProps): return ( - Terrain + {chartLabel} - {TERRAIN_GROUPS.map((g) => ( + {groups.map((g) => ( {g.abbr} diff --git a/packages/engine-ts/src/runner.ts b/packages/engine-ts/src/runner.ts index a78e3929..9f9a4b36 100644 --- a/packages/engine-ts/src/runner.ts +++ b/packages/engine-ts/src/runner.ts @@ -159,9 +159,13 @@ export function computeTurnStats( let etSum = 0 let landCount = 0 const terrain_counts: Record = {} + const substrate_counts: Record = {} for (const tile of tiles) { terrain_counts[tile.biome_id] = (terrain_counts[tile.biome_id] ?? 0) + 1 + if (tile.substrate_id) { + substrate_counts[tile.substrate_id] = (substrate_counts[tile.substrate_id] ?? 0) + 1 + } const isWater = tile.biome_id === 'ocean' || tile.biome_id === 'coast' || tile.biome_id === 'lake' || tile.biome_id === 'inland_sea' || tile.biome_id === 'deep_ocean' || tile.biome_id === 'shallow_ocean' || @@ -221,6 +225,7 @@ export function computeTurnStats( net_energy: prevStats ? (landCount > 0 ? tempSum / landCount : 0.5) - prevStats.avg_temp : 0, net_hydro: prevStats ? (landCount > 0 ? moistSum / landCount : 0.5) - prevStats.avg_moisture : 0, terrain_counts, + substrate_counts, } } diff --git a/packages/engine-ts/src/types.ts b/packages/engine-ts/src/types.ts index 8ffb0872..eac0168d 100644 --- a/packages/engine-ts/src/types.ts +++ b/packages/engine-ts/src/types.ts @@ -79,7 +79,8 @@ export interface TurnStats { avg_evapotranspiration: number // average ET contribution across land tiles net_energy: number // net energy balance: solar_in - radiative_loss (positive=warming, negative=cooling) net_hydro: number // net water balance: evap+ET - decay-loss (positive=wetting, negative=drying) - terrain_counts: Record + terrain_counts: Record // biome_id → tile count + substrate_counts: Record // substrate_id → tile count } // Packed per-tile data for rendering: 3 RGBA float textures