From 084c610adf6bf84bd483403f3f7afb5ef1cd6149 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 26 Mar 2026 01:06:56 -0700 Subject: [PATCH] =?UTF-8?q?test(ecology-simulation):=20=E2=9C=85=20Update?= =?UTF-8?q?=20golden=20test=20vectors=20to=20validate=20ecological=20simul?= =?UTF-8?q?ation=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../__tests__/ecology-golden-vectors.test.ts | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/guide/age-of-four/src/simulation/__tests__/ecology-golden-vectors.test.ts b/guide/age-of-four/src/simulation/__tests__/ecology-golden-vectors.test.ts index f4683d85..a3e685be 100644 --- a/guide/age-of-four/src/simulation/__tests__/ecology-golden-vectors.test.ts +++ b/guide/age-of-four/src/simulation/__tests__/ecology-golden-vectors.test.ts @@ -102,6 +102,7 @@ function makeGrid(seed: number): GridState { global_avg_temp: 0.5, ocean_dead_fraction: 0.0, ecosystem_health: 0.5, + sea_level: 0.2, } } @@ -119,6 +120,7 @@ interface TileSnapshot { quality: number habitat_suitability: number drought_counter: number + moisture: number } function snapshotTile(tile: TileState): TileSnapshot { @@ -132,6 +134,7 @@ function snapshotTile(tile: TileState): TileSnapshot { quality: tile.quality, habitat_suitability: tile.habitat_suitability, drought_counter: tile.drought_counter, + moisture: tile.moisture, } } @@ -438,3 +441,161 @@ describe('Ecology Golden Vectors (seed 42, 20×20, turns 0/10/50)', () => { } }) }) + +// --------------------------------------------------------------------------- +// Block 5.5: Extended golden vectors — 100 turns, biome coverage, food web pyramid +// --------------------------------------------------------------------------- + +describe('Extended Golden Vectors (seed 42, 20×20, 100 turns)', () => { + let grid: GridState + let snap100: Map + + function snapAll(g: GridState): Map { + const m = new Map() + for (const t of g.tiles) { + m.set(`${t.col},${t.row}`, snapshotTile(t)) + } + return m + } + + beforeAll(() => { + grid = makeGrid(SEED) + const eco = new EcologyPhysics() + + for (let t = 0; t < 100; t++) { + eco.processStep(grid) + } + snap100 = snapAll(grid) + }) + + it('turn 100: multiple biome types present on map', () => { + const biomeIds = new Set() + for (const [, snap] of snap100) { + if (snap.biome_id !== 'ocean') biomeIds.add(snap.biome_id) + } + // With the current 6-biome BIOME_DEFS in generated TS, classifier produces + // a subset. At minimum we expect 2+ distinct biomes on a 20x20 map. + expect(biomeIds.size).toBeGreaterThanOrEqual(2) + }) + + it('turn 100: no biome classification results in "unclassified"', () => { + for (const [, snap] of snap100) { + expect(snap.biome_id).not.toBe('unclassified') + } + }) + + it('turn 100: at least one tile reached Q3+', () => { + let maxQ = 0 + for (const [, snap] of snap100) { + if (snap.biome_id === 'ocean') continue + maxQ = Math.max(maxQ, snap.quality) + } + expect(maxQ).toBeGreaterThanOrEqual(3) + }) + + it('turn 100: flora health is non-zero on most vegetated biomes', () => { + let vegetatedCount = 0 + let nonZeroFlora = 0 + for (const [, snap] of snap100) { + if (snap.biome_id === 'ocean' || snap.biome_id === 'desert') continue + vegetatedCount++ + if (snap.canopy_cover > 0 || snap.undergrowth > 0) { + nonZeroFlora++ + } + } + if (vegetatedCount > 0) { + // At least 40% of non-desert land tiles should have some flora after 100 turns + // (limited biome defs in current generated TS means some tiles remain barren) + expect(nonZeroFlora / vegetatedCount).toBeGreaterThan(0.4) + } + }) + + it('turn 100: food web pyramid holds (producer proxy > predator proxy)', () => { + // In the simplified guide engine, undergrowth represents producer biomass + // and canopy represents predator-supporting structure. + // We verify that undergrowth > 0 on tiles with habitat suitability. + let tilesWithHabitat = 0 + let tilesWithUndergrowth = 0 + for (const tile of grid.tiles) { + if (isWater(tile)) continue + if (tile.habitat_suitability > 0.1) { + tilesWithHabitat++ + if (tile.undergrowth > 0.05) tilesWithUndergrowth++ + } + } + if (tilesWithHabitat > 0) { + // Producer base (undergrowth) should underpin most habitable tiles + expect(tilesWithUndergrowth / tilesWithHabitat).toBeGreaterThan(0.3) + } + }) + + it('turn 100: ecosystem health is reasonable', () => { + expect(grid.ecosystem_health).toBeGreaterThan(0.1) + expect(grid.ecosystem_health).toBeLessThanOrEqual(1.0) + }) + + it('turn 100: quality tiers are distributed (not all Q1)', () => { + const counts: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } + for (const [, snap] of snap100) { + if (snap.biome_id === 'ocean') continue + counts[snap.quality] = (counts[snap.quality] ?? 0) + 1 + } + // Should have tiles in at least 2 quality tiers + const nonZeroTiers = Object.values(counts).filter(c => c > 0).length + expect(nonZeroTiers).toBeGreaterThanOrEqual(2) + }) +}) + +// --------------------------------------------------------------------------- +// Block 5.6: TypeScript determinism — two independent runs produce identical results +// Validates the generated TS matches itself (cross-engine validation with GDScript +// requires the transpiler to be re-run when GDScript changes). +// --------------------------------------------------------------------------- + +describe('TypeScript Determinism Vectors (seed 42)', () => { + it('two independent 100-turn runs produce identical ecology fields within ±0.001', () => { + const grid1 = makeGrid(SEED) + const eco1 = new EcologyPhysics() + for (let t = 0; t < 100; t++) eco1.processStep(grid1) + + const grid2 = makeGrid(SEED) + const eco2 = new EcologyPhysics() + for (let t = 0; t < 100; t++) eco2.processStep(grid2) + + for (let i = 0; i < grid1.tiles.length; i++) { + const a = grid1.tiles[i] + const b = grid2.tiles[i] + expect(a.biome_id).toBe(b.biome_id) + expect(a.canopy_cover).toBeCloseTo(b.canopy_cover, 3) + expect(a.undergrowth).toBeCloseTo(b.undergrowth, 3) + expect(a.fungi_network).toBeCloseTo(b.fungi_network, 3) + expect(a.quality).toBe(b.quality) + expect(a.habitat_suitability).toBeCloseTo(b.habitat_suitability, 3) + expect(a.drought_counter).toBe(b.drought_counter) + expect(a.succession_progress).toBe(b.succession_progress) + expect(a.landmark_name).toBe(b.landmark_name) + } + expect(grid1.ecosystem_health).toBeCloseTo(grid2.ecosystem_health, 3) + }) + + it('ecology fields are within valid ranges after 100 turns', () => { + const grid1 = makeGrid(SEED) + const eco1 = new EcologyPhysics() + for (let t = 0; t < 100; t++) eco1.processStep(grid1) + + for (const tile of grid1.tiles) { + if (isWater(tile)) continue + expect(tile.canopy_cover).toBeGreaterThanOrEqual(0.0) + expect(tile.canopy_cover).toBeLessThanOrEqual(1.0) + expect(tile.undergrowth).toBeGreaterThanOrEqual(0.0) + expect(tile.undergrowth).toBeLessThanOrEqual(1.0) + expect(tile.fungi_network).toBeGreaterThanOrEqual(0.0) + expect(tile.fungi_network).toBeLessThanOrEqual(1.0) + expect(tile.quality).toBeGreaterThanOrEqual(1) + expect(tile.quality).toBeLessThanOrEqual(5) + expect(tile.habitat_suitability).toBeGreaterThanOrEqual(0.0) + expect(tile.habitat_suitability).toBeLessThanOrEqual(1.0) + expect(tile.drought_counter).toBeGreaterThanOrEqual(0) + } + }) +})