magicciv/tools/climate-diag.ts
2026-04-07 17:52:04 -07:00

203 lines
8 KiB
TypeScript

#!/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<string, number>
}
// ── 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<string, Record<string, unknown>> {
const terrainDir = path.join(ROOT, 'public/games/age-of-dwarves/data/terrain')
const cache = new Map<string, Record<string, unknown>>()
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<string, number> {
const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, unknown>
const flat: Record<string, number> = {}
for (const [k, v] of Object.entries(raw)) {
if (typeof v === 'number') flat[k] = v
}
return flat
}
function loadSpec(): Record<string, unknown> {
if (!fs.existsSync(specPath)) return {}
return JSON.parse(fs.readFileSync(specPath, 'utf-8')) as Record<string, unknown>
}
// ── 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`)
}
}