283 lines
10 KiB
JavaScript
283 lines
10 KiB
JavaScript
#!/usr/bin/env -S node --import tsx/esm
|
|
/**
|
|
* bake-simcache — pre-compute climate-simulator scenarios at build time
|
|
* and emit static files that match the `/__sim-cache/:id/{status,frame/:n}`
|
|
* wire format the Vite dev plugin serves.
|
|
*
|
|
* Output layout (under `dist/` after running, relative to the guide package):
|
|
* dist/__sim-cache/<scenarioId>/status JSON { ready, totalTurns, frameWidth, frameHeight }
|
|
* dist/__sim-cache/<scenarioId>/frame/<n> binary wire format
|
|
* dist/__sim-cache/<scenarioId>/data.json full stats + events
|
|
*
|
|
* Wire format (one per frame, byte-for-byte identical to what
|
|
* simCachePlugin's /frame/:turn endpoint produces):
|
|
* [metaLen:uint32LE][metaJSON:utf8][texA Float32*][texB Float32*][texC Float32*]
|
|
*
|
|
* Static nginx cannot inject per-file X-Frame-Width / X-Frame-Height
|
|
* response headers the dev plugin sets, so width+height are inlined into
|
|
* the meta JSON as well — the frontend reads whichever source is present.
|
|
*
|
|
* Implements objective p2-21 (`.project/objectives/p2-21-guide-simcache-static-bake.md`),
|
|
* owned by tourguide. Depends on p2-20's runner-stub.mjs — without it, the
|
|
* first @magic-civ/physics-rs import fails under Node+tsx.
|
|
*/
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import {
|
|
SCENARIOS,
|
|
buildTerrainCacheFromData,
|
|
runScenarioSync,
|
|
type TerrainData,
|
|
type LeyEdge,
|
|
type WonderMarker,
|
|
type RiverSegment,
|
|
type RiverSourceMarker,
|
|
type WindArrow,
|
|
} from '@magic-civ/engine-ts'
|
|
|
|
interface PerTurnStat {
|
|
readonly turn: number
|
|
readonly global_avg_temp: number
|
|
readonly ocean_dead_fraction: number
|
|
readonly [k: string]: unknown
|
|
}
|
|
|
|
interface Snapshot {
|
|
readonly texA: Float32Array
|
|
readonly texB: Float32Array
|
|
readonly texC: Float32Array
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly turn: number
|
|
readonly global_avg_temp: number
|
|
readonly ocean_dead_fraction: number
|
|
readonly ley_edges: readonly LeyEdge[]
|
|
readonly wonder_positions: readonly WonderMarker[]
|
|
readonly riverSegments: readonly RiverSegment[]
|
|
readonly riverSources: readonly RiverSourceMarker[]
|
|
readonly windArrows: readonly WindArrow[]
|
|
readonly stats: PerTurnStat
|
|
readonly events: readonly unknown[]
|
|
}
|
|
|
|
interface BakeSpec {
|
|
readonly scenarioId: string
|
|
readonly seed: number
|
|
readonly turns: number
|
|
}
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
const GUIDE_ROOT = path.resolve(HERE, '..')
|
|
// GUIDE_ROOT is <repo>/public/games/age-of-dwarves/guide — four `..` hops to repo root.
|
|
const REPO_ROOT = path.resolve(GUIDE_ROOT, '../../../..')
|
|
const DIST_DIR = path.join(GUIDE_ROOT, 'dist')
|
|
|
|
// Paths come from the repo-root .env (loaded by scripts/run/common.sh before
|
|
// `./run` dispatches to this script). .env defines:
|
|
// GUIDE_TERRAIN_DIR — dir containing terrain JSON definitions
|
|
// GUIDE_CLIMATE_PARAMS — path to the earth climate_params.json
|
|
// Both are relative to the repo root. Falling back to hardcoded defaults
|
|
// keeps the script runnable ad-hoc (e.g. under vitest or from an IDE), but
|
|
// the canonical source of truth is .env.
|
|
function envPath(envKey: string, fallback: string): string {
|
|
const raw = process.env[envKey]
|
|
const rel = raw && raw.trim().length > 0 ? raw : fallback
|
|
return path.isAbsolute(rel) ? rel : path.join(REPO_ROOT, rel)
|
|
}
|
|
|
|
const TERRAIN_DIR = envPath('GUIDE_TERRAIN_DIR', 'public/resources/tiles')
|
|
const PARAMS_PATH = envPath('GUIDE_CLIMATE_PARAMS', 'public/resources/worlds/earth/climate_params.json')
|
|
|
|
// Mirror `simCachePlugin.PREWARM_IDS` + fixed query-string defaults the
|
|
// frontend sends (`seed=42&turns=2000`). Keep these two in sync if the
|
|
// plugin's list changes.
|
|
const ALL_SCENARIO_IDS = [
|
|
'base_no_magic',
|
|
'hadean_earth',
|
|
'ice_age',
|
|
'desertification',
|
|
'ecological_collapse',
|
|
'volcanic_winter',
|
|
] as const satisfies readonly string[]
|
|
|
|
type ScenarioId = typeof ALL_SCENARIO_IDS[number]
|
|
|
|
function parseCliScenarios(argv: readonly string[]): readonly ScenarioId[] {
|
|
// Supported forms:
|
|
// node bake-simcache.ts → all scenarios
|
|
// node bake-simcache.ts base_no_magic → one scenario
|
|
// node bake-simcache.ts a,b,c → multiple (comma-separated)
|
|
// BAKE_SCENARIOS="a,b" node bake-simcache.ts → env var override
|
|
const fromArgv = argv.slice(2).flatMap(arg => arg.split(',')).filter(Boolean)
|
|
const fromEnv = (process.env.BAKE_SCENARIOS ?? '').split(',').map(s => s.trim()).filter(Boolean)
|
|
const requested = fromArgv.length > 0 ? fromArgv : fromEnv
|
|
if (requested.length === 0) return ALL_SCENARIO_IDS
|
|
const known = new Set<string>(ALL_SCENARIO_IDS)
|
|
const unknown = requested.filter(id => !known.has(id))
|
|
if (unknown.length > 0) {
|
|
throw new Error(`bake-simcache: unknown scenario ids: ${unknown.join(', ')}. Known: ${ALL_SCENARIO_IDS.join(', ')}`)
|
|
}
|
|
return requested as ScenarioId[]
|
|
}
|
|
|
|
const DEFAULT_SEED = 42
|
|
const DEFAULT_TURNS = 2000
|
|
|
|
// CLI progress writer. Build-time script; structured logging framework
|
|
// is overkill for a stdout progress trail that only a human reads.
|
|
function log(line: string): void {
|
|
process.stdout.write(line + '\n')
|
|
}
|
|
|
|
function loadTerrainData(): Record<string, TerrainData> {
|
|
const out: Record<string, TerrainData> = {}
|
|
for (const file of fs.readdirSync(TERRAIN_DIR).filter(f => f.endsWith('.json'))) {
|
|
const raw = JSON.parse(
|
|
fs.readFileSync(path.join(TERRAIN_DIR, file), 'utf8')
|
|
) as { terrains?: TerrainData[] }
|
|
if (raw.terrains) {
|
|
for (const t of raw.terrains) out[t.id] = t
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
function loadClimateParams(): Record<string, unknown> {
|
|
return JSON.parse(fs.readFileSync(PARAMS_PATH, 'utf8')) as Record<string, unknown>
|
|
}
|
|
|
|
function writeFrame(
|
|
framePath: string,
|
|
snap: Snapshot,
|
|
frameWidth: number,
|
|
frameHeight: number,
|
|
): number {
|
|
const floatsPerTex = frameWidth * frameHeight * 4
|
|
const bytesPerTex = floatsPerTex * 4
|
|
const frameBytes = bytesPerTex * 3
|
|
|
|
const meta = {
|
|
turn: snap.turn,
|
|
global_avg_temp: snap.global_avg_temp,
|
|
ocean_dead_fraction: snap.ocean_dead_fraction,
|
|
ley_edges: snap.ley_edges,
|
|
wonder_positions: snap.wonder_positions,
|
|
riverSegments: snap.riverSegments,
|
|
riverSources: snap.riverSources,
|
|
windArrows: snap.windArrows,
|
|
frameWidth,
|
|
frameHeight,
|
|
}
|
|
const metaJson = JSON.stringify(meta)
|
|
const metaBytes = Buffer.from(metaJson, 'utf8')
|
|
const metaLen = metaBytes.length
|
|
|
|
const body = Buffer.allocUnsafe(4 + metaLen + frameBytes)
|
|
body.writeUInt32LE(metaLen, 0)
|
|
metaBytes.copy(body, 4)
|
|
|
|
let offset = 4 + metaLen
|
|
for (const tex of [snap.texA, snap.texB, snap.texC]) {
|
|
const src = Buffer.from(tex.buffer, tex.byteOffset, tex.byteLength)
|
|
src.copy(body, offset)
|
|
offset += tex.byteLength
|
|
}
|
|
|
|
fs.writeFileSync(framePath, body)
|
|
return body.length
|
|
}
|
|
|
|
async function bake(
|
|
spec: BakeSpec,
|
|
terrainCache: unknown,
|
|
climateParams: Record<string, unknown>,
|
|
): Promise<{ bytes: number; frames: number }> {
|
|
const config = SCENARIOS.find(s => s.id === spec.scenarioId)
|
|
if (!config) {
|
|
throw new Error(`bake-simcache: unknown scenario "${spec.scenarioId}" — check BAKE_SPECS against @magic-civ/engine-ts SCENARIOS`)
|
|
}
|
|
|
|
const t0 = Date.now()
|
|
const result = runScenarioSync(config, terrainCache, climateParams, spec.turns, spec.seed)
|
|
const snapshots = result.snapshots as readonly Snapshot[]
|
|
if (snapshots.length === 0) {
|
|
throw new Error(`bake-simcache: ${spec.scenarioId} produced 0 snapshots`)
|
|
}
|
|
|
|
const first = snapshots[0]
|
|
const { width: frameWidth, height: frameHeight } = first
|
|
|
|
const scenarioOutDir = path.join(DIST_DIR, '__sim-cache', spec.scenarioId)
|
|
const frameDir = path.join(scenarioOutDir, 'frame')
|
|
fs.mkdirSync(frameDir, { recursive: true })
|
|
|
|
fs.writeFileSync(
|
|
path.join(scenarioOutDir, 'status'),
|
|
JSON.stringify({ ready: true, totalTurns: snapshots.length, frameWidth, frameHeight }),
|
|
)
|
|
|
|
let totalBytes = 0
|
|
for (let i = 0; i < snapshots.length; i++) {
|
|
const framePath = path.join(frameDir, String(i))
|
|
totalBytes += writeFrame(framePath, snapshots[i], frameWidth, frameHeight)
|
|
}
|
|
|
|
fs.writeFileSync(
|
|
path.join(scenarioOutDir, 'data.json'),
|
|
JSON.stringify({
|
|
scenarioId: spec.scenarioId,
|
|
seed: spec.seed,
|
|
turns: spec.turns,
|
|
stats: snapshots.map(s => s.stats),
|
|
events: snapshots.map(s => s.events),
|
|
}),
|
|
)
|
|
|
|
const elapsedMs = Date.now() - t0
|
|
const mib = (totalBytes / (1024 * 1024)).toFixed(1)
|
|
log(`[bake] ${spec.scenarioId.padEnd(22)} ${snapshots.length} frames · ${mib} MiB · ${(elapsedMs / 1000).toFixed(1)}s`)
|
|
return { bytes: totalBytes, frames: snapshots.length }
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
if (!fs.existsSync(DIST_DIR)) {
|
|
process.stderr.write(`[bake] dist/ missing at ${DIST_DIR}; run \`pnpm build\` first, then bake.\n`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const scenarios = parseCliScenarios(process.argv)
|
|
const specs: readonly BakeSpec[] = scenarios.map(id => ({
|
|
scenarioId: id,
|
|
seed: DEFAULT_SEED,
|
|
turns: DEFAULT_TURNS,
|
|
}))
|
|
|
|
log('[bake] loading terrain + climate params')
|
|
const terrainData = loadTerrainData()
|
|
const climateParams = loadClimateParams()
|
|
const terrainCache = buildTerrainCacheFromData(terrainData)
|
|
|
|
log(`[bake] ${specs.length} scenario(s) queued — ${scenarios.join(', ')} — seed=${DEFAULT_SEED}, turns=${DEFAULT_TURNS}`)
|
|
const t0 = Date.now()
|
|
let totalBytes = 0
|
|
let totalFrames = 0
|
|
|
|
// Serial rather than Promise.all: WASM is CPU-bound single-threaded,
|
|
// parallel just thrashes the scheduler. ~3 min per scenario.
|
|
for (const spec of specs) {
|
|
const { bytes, frames } = await bake(spec, terrainCache, climateParams)
|
|
totalBytes += bytes
|
|
totalFrames += frames
|
|
}
|
|
|
|
const elapsedS = ((Date.now() - t0) / 1000).toFixed(1)
|
|
const totalMiB = (totalBytes / (1024 * 1024)).toFixed(1)
|
|
log(`[bake] done — ${specs.length} scenario(s) · ${totalFrames} frames · ${totalMiB} MiB · ${elapsedS}s`)
|
|
}
|
|
|
|
main().catch((err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
process.stderr.write(`[bake] failed: ${msg}\n`)
|
|
process.exit(1)
|
|
})
|