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:
parent
1f67736b6e
commit
5bd982c62b
4 changed files with 163 additions and 10 deletions
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue