#!/usr/bin/env npx tsx /** * Climate Diagnostic CLI — runs the TS climate engine headlessly and dumps * per-turn stats to stdout as TSV or JSON. No browser, no Vite, no worker. * * Usage: * npx tsx tools/climate-diag.ts # baseline, 200 turns, every 10 * npx tsx tools/climate-diag.ts --scenario ice_age --turns 500 --every 50 * npx tsx tools/climate-diag.ts --json --turns 100 --every 25 * npx tsx tools/climate-diag.ts --terrain-detail # full terrain breakdown * npx tsx tools/climate-diag.ts --params path/to/tweaked.json */ import * as fs from 'fs' import * as path from 'path' import type { GridState, ScenarioConfig, TurnStats } from '../packages/engine-ts/src/types' import { WasmClimatePhysics, WasmMapGenerator, WasmGrid } from '@magic-civ/physics-rs' import { GRID_WIDTH, GRID_HEIGHT } from '../packages/engine-ts/src/HexGrid' import { computeTurnStats } from '../packages/engine-ts/src/runner' import { SCENARIOS } from '../packages/engine-ts/src/scenarios' interface TerrainFileData { terrains: Array<{ id: string; [k: string]: unknown }> } interface TurnRow { turn: number avg_temp: number avg_moisture: number temp_delta: number moisture_delta: number ocean_dead: number events: number event_types: string terrain_counts: Record } // ── CLI args ───────────────────────────────────────────────────────────────── const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..') const args = process.argv.slice(2) function flag(name: string): boolean { return args.includes(`--${name}`) } function opt(name: string, fallback: string): string { const i = args.indexOf(`--${name}`) return i >= 0 && i + 1 < args.length ? args[i + 1] : fallback } const scenarioId = opt('scenario', 'base_no_magic') const totalTurns = parseInt(opt('turns', '200'), 10) const every = parseInt(opt('every', '10'), 10) const jsonMode = flag('json') const terrainDetail = flag('terrain-detail') const paramsPath = opt('params', path.join(ROOT, 'public/games/age-of-dwarves/data/climate_params.json')) const specPath = path.join(ROOT, 'public/games/age-of-dwarves/data/climate_spec.json') // ── Load data from disk (bypass Vite) ──────────────────────────────────────── function loadTerrainCache(): Map> { const terrainDir = path.join(ROOT, 'public/games/age-of-dwarves/data/terrain') const cache = new Map>() for (const file of fs.readdirSync(terrainDir)) { if (!file.endsWith('.json')) continue const data = JSON.parse(fs.readFileSync(path.join(terrainDir, file), 'utf-8')) as TerrainFileData if (Array.isArray(data.terrains)) { for (const t of data.terrains) cache.set(t.id, t) } } return cache } function loadParams(filePath: string): Record { const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record const flat: Record = {} for (const [k, v] of Object.entries(raw)) { if (typeof v === 'number') flat[k] = v } return flat } function loadSpec(): Record { if (!fs.existsSync(specPath)) return {} return JSON.parse(fs.readFileSync(specPath, 'utf-8')) as Record } // ── Find scenario ──────────────────────────────────────────────────────────── const scenario = SCENARIOS.find((s: ScenarioConfig) => s.id === scenarioId) if (!scenario) { const ids = SCENARIOS.map((s: ScenarioConfig) => s.id).join(', ') process.stderr.write(`Unknown scenario: ${scenarioId}\nAvailable: ${ids}\n`) process.exit(1) } // ── Run simulation ─────────────────────────────────────────────────────────── const terrainCache = loadTerrainCache() const params = loadParams(paramsPath) const spec = loadSpec() const SEED = 42 process.stderr.write(`Running "${scenario.name}" for ${totalTurns} turns (reporting every ${every})...\n`) const paramsJson = JSON.stringify(params) const terrainJson = JSON.stringify(Object.fromEntries(terrainCache)) const specJson = JSON.stringify(spec) const mapGen = new WasmMapGenerator(paramsJson) const wasmMapGrid = mapGen.generate(SEED, 'continents') let grid: GridState = wasmMapGrid.toJSON() as GridState wasmMapGrid.free() mapGen.free() const physics = new WasmClimatePhysics(paramsJson, terrainJson, specJson) const worldAge = scenario.worldAge ?? 0 // Phase 1: geological history — run in WASM let wasmGrid = WasmGrid.fromJSON(grid) for (let t = 0; t < worldAge; t++) { physics.processStep(wasmGrid, t, SEED) } grid = wasmGrid.toJSON() as GridState wasmGrid.free() // Phase 2: scenario init scenario.initMap(grid) // Phase 3: scenario simulation with recording const rows: TurnRow[] = [] let baseTemp = 0 let baseMoist = 0 wasmGrid = WasmGrid.fromJSON(grid) for (let t = worldAge; t < worldAge + totalTurns; t++) { const scenarioTurn = t - worldAge physics.processStep(wasmGrid, t, SEED) grid = wasmGrid.toJSON() as GridState const stats: TurnStats = computeTurnStats(grid) if (scenarioTurn === 0) { baseTemp = stats.avg_temp baseMoist = stats.avg_moisture } if (scenarioTurn % every === 0 || scenarioTurn === totalTurns - 1) { rows.push({ turn: scenarioTurn, avg_temp: stats.avg_temp, avg_moisture: stats.avg_moisture, temp_delta: stats.avg_temp - baseTemp, moisture_delta: stats.avg_moisture - baseMoist, ocean_dead: grid.ocean_dead_fraction, events: 0, event_types: '', terrain_counts: stats.terrain_counts, }) } } // ── Output ─────────────────────────────────────────────────────────────────── if (jsonMode) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n') } else { const topTerrains = ['deep_ocean', 'shallow_ocean', 'temperate_grassland', 'temperate_forest', 'tropical_rainforest', 'desert', 'tundra', 'alpine_tundra', 'boreal_forest', 'savanna'] const lastRow = rows[rows.length - 1] const terrainCols = terrainDetail ? Object.keys(lastRow?.terrain_counts ?? {}).sort() : topTerrains const header = ['turn', 'avg_temp', 'avg_moist', 'Δtemp', 'Δmoist', 'ocean_dead', 'events', ...terrainCols.map(t => t.substring(0, 8))].join('\t') process.stdout.write(header + '\n') for (const row of rows) { const cols = [ row.turn.toString(), row.avg_temp.toFixed(4), row.avg_moisture.toFixed(4), (row.temp_delta >= 0 ? '+' : '') + row.temp_delta.toFixed(4), (row.moisture_delta >= 0 ? '+' : '') + row.moisture_delta.toFixed(4), row.ocean_dead.toFixed(4), row.events.toString(), ...terrainCols.map(t => (row.terrain_counts[t] ?? 0).toString()), ] process.stdout.write(cols.join('\t') + '\n') } // Summary to stderr const first = rows[0] const last = lastRow process.stderr.write(`\n── Summary (${scenario.name}, ${totalTurns} turns) ──\n`) process.stderr.write(`Temp: ${first.avg_temp.toFixed(4)} → ${last.avg_temp.toFixed(4)} (Δ${last.temp_delta >= 0 ? '+' : ''}${last.temp_delta.toFixed(4)})\n`) process.stderr.write(`Moisture: ${first.avg_moisture.toFixed(4)} → ${last.avg_moisture.toFixed(4)} (Δ${last.moisture_delta >= 0 ? '+' : ''}${last.moisture_delta.toFixed(4)})\n`) process.stderr.write(`Events: ${rows.reduce((s, r) => s + r.events, 0)} total\n`) if (Math.abs(last.temp_delta) > 0.02) { process.stderr.write(`⚠ DRIFT: Temperature drifted ${last.temp_delta.toFixed(4)} from baseline — equilibrium_relaxation may need tuning\n`) } }