203 lines
8 KiB
TypeScript
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`)
|
|
}
|
|
}
|