magicciv/public/games/age-of-dwarves/guide/tools/bake-simcache.ts
Natalie c6bcc5ba91 feat(@projects/@magic-civilization): add guide deployment and resource paths
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-17 15:36:01 -07:00

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)
})