test(simulation): Add test coverage for terrain chart grouping and update StatsDashboard visualization

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 01:31:52 -07:00
parent 1f67736b6e
commit 5bd982c62b
4 changed files with 163 additions and 10 deletions

View file

@ -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<string, number>): 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<string>()
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<string, number> = {
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<string, number> = { 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<string, number> = {
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<string, number> = {}
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)
}
})
})

View file

@ -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<HTMLCanvasElement>(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<string, unknown>)[countsKey] as Record<string, number> | 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<HTMLCanvasElement>) => {
if (stats.length === 0) return
@ -441,14 +461,14 @@ function TerrainDistChart({ stats, currentTurn, onScrub }: StatsDashboardProps):
return (
<TerrainColumn>
<TerrainHeader>Terrain</TerrainHeader>
<TerrainHeader>{chartLabel}</TerrainHeader>
<canvas
ref={canvasRef}
onClick={handleClick}
style={{ cursor: 'crosshair', display: 'block', width: TERRAIN_CHART_W, height: chartH }}
/>
<TerrainLegendRow style={{ height: legendH }}>
{TERRAIN_GROUPS.map((g) => (
{groups.map((g) => (
<LegendItem key={g.abbr}>
<LegendDot style={{ background: g.color }} />
<LegendAbbr>{g.abbr}</LegendAbbr>

View file

@ -159,9 +159,13 @@ export function computeTurnStats(
let etSum = 0
let landCount = 0
const terrain_counts: Record<string, number> = {}
const substrate_counts: Record<string, number> = {}
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,
}
}

View file

@ -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<string, number>
terrain_counts: Record<string, number> // biome_id → tile count
substrate_counts: Record<string, number> // substrate_id → tile count
}
// Packed per-tile data for rendering: 3 RGBA float textures