feat(engine): Add climate physics, ecology simulation, and map generation logic with new models, runner updates, and type definitions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 11:38:31 -07:00
parent f7f5d3feb3
commit e1fe0afe7f
6 changed files with 21 additions and 61 deletions

View file

@ -60,6 +60,7 @@ export function idealTerrain(
const temp = tile.temperature
const moist = tile.moisture
const elev = tile.elevation
const canopy = tile.canopy_cover ?? 0
const transitions = (spec['terrain_transitions'] ?? {}) as Record<string, Array<Record<string, unknown>>>
const rules: Array<Record<string, unknown>> = transitions[tid] ?? []
@ -67,7 +68,7 @@ export function idealTerrain(
for (const rule of rules) {
const cond = rule['condition'] as string ?? ''
if (evalCondition(cond, temp, moist, elev)) {
if (evalCondition(cond, temp, moist, elev, canopy)) {
const becomes = rule['becomes'] as string ?? ''
if (becomes === 'classify') return classifyTerrain(temp, moist, elev)
return becomes
@ -88,17 +89,17 @@ export function leyChannelingMult(
return leySpec['on_ley_generic'] ?? 2.0
}
export function evalCondition(cond: string, temp: number, moist: number, elev: number): boolean {
export function evalCondition(cond: string, temp: number, moist: number, elev: number, canopy = 0): boolean {
if (cond.includes(' OR ')) {
return cond.split(' OR ').some(part => evalCondition(part.trim(), temp, moist, elev))
return cond.split(' OR ').some(part => evalCondition(part.trim(), temp, moist, elev, canopy))
}
if (cond.includes(' AND ')) {
return cond.split(' AND ').every(part => evalCondition(part.trim(), temp, moist, elev))
return cond.split(' AND ').every(part => evalCondition(part.trim(), temp, moist, elev, canopy))
}
let clean = cond.trim()
if (clean.startsWith('(') && clean.endsWith(')')) clean = clean.slice(1, -1)
const tokens = clean.trim().split(' ')
if (tokens.length < 3) { console.warn(`ClimateSpecEval: bad condition: '${cond}'`); return false }
if (tokens.length < 3) return false
const [field, op, valStr] = tokens
const value = parseFloat(valStr)
let actual: number
@ -106,7 +107,8 @@ export function evalCondition(cond: string, temp: number, moist: number, elev: n
case 'temperature': actual = temp; break
case 'moisture': actual = moist; break
case 'elevation': actual = elev; break
default: console.warn(`ClimateSpecEval: unknown field '${field}'`); return false
case 'canopy': actual = canopy; break
default: return false
}
switch (op) {
case '<': return actual < value
@ -265,7 +267,7 @@ export class ClimatePhysics {
let any_aerosol = false
for (let i = 0; i < tiles.length; i++) {
if (((tiles[i] as any).sulfate_aerosol ?? 0) > 0.001) { any_aerosol = true; break }
if (((tiles[i] as any).sulfate_aerosol ?? 0) > 0.1) { any_aerosol = true; break }
}
if (!any_aerosol) return
const cooling_rate = aerosol_cfg['cooling_rate'] ?? 0.06

View file

@ -56,7 +56,7 @@ function getBiome(biomeId: string): BiomeDef | null {
// ---------------------------------------------------------------------------
function _getSubstrate(tile: TileState): string {
if (true) {
if (tile.substrate_id !== "") {
return tile.substrate_id
}
return ""
@ -262,7 +262,7 @@ function classifyBiome(tile: TileState): string {
// ---------------------------------------------------------------------------
function _isWater(tile: TileState): boolean {
if (true) {
if (tile.substrate_id !== "") {
let sub = tile.substrate_id
return ["deep_water", "shallow_water", "lake_bed"].includes(sub)
}

View file

@ -249,7 +249,7 @@ class GenMap {
wind_direction: gt?.wind_direction ?? 0,
wind_speed: gt?.wind_speed ?? 0.5,
quality: gt?.quality ?? 2,
quality_progress: gt?.quality_progress ?? 0,
quality_progress: gt?.quality_progress ?? (((col * 7 + row * 13) % 11) - 5), // stagger -5..+5 to prevent synchronized biome flips
river_edges: gt?.river_edges ?? [],
flow_accumulation: gt?.flow_accumulation ?? 0.0,
original_biome_id: '',
@ -272,6 +272,7 @@ class GenMap {
pressure: 1013.0,
pressure_anomaly: 0.0,
humidity: 0.0,
sulfate_aerosol: 0.0,
// Ecology fields (populated by EcologyPhysics)
canopy_cover: 0.0,
undergrowth: 0.0,

View file

@ -81,50 +81,6 @@ function encodeTerrainId(terrainId: string): number {
return i / (TERRAIN_ORDER.length - 1)
}
// ---------------------------------------------------------------------------
// Terrain cache
// ---------------------------------------------------------------------------
export function buildTerrainCache(): Map<string, TerrainData> {
const terrainMods = import.meta.glob(
'@data/terrain/*.json',
{ eager: true, import: 'default' },
) as Record<string, { terrains: TerrainData[] }>
const cache = new Map<string, TerrainData>()
for (const mod of Object.values(terrainMods)) {
if (!mod?.terrains) continue
for (const terrain of mod.terrains) {
cache.set(terrain.id, terrain)
}
}
return cache
}
// ---------------------------------------------------------------------------
// Params loading
// ---------------------------------------------------------------------------
type ClimateParamsRaw = Record<string, number | Record<string, unknown>>
function flattenParams(raw: ClimateParamsRaw): Record<string, number> {
const out: Record<string, number> = {}
for (const [k, v] of Object.entries(raw)) {
if (typeof v === 'number') out[k] = v
}
return out
}
export function loadClimateParams(): Record<string, number> {
const mods = import.meta.glob(
'@data/climate_params.json',
{ eager: true, import: 'default' },
) as Record<string, ClimateParamsRaw>
const raw = Object.values(mods)[0]
if (!raw) throw new Error('climate_params.json not found via @data/ alias')
return flattenParams(raw)
}
// ---------------------------------------------------------------------------
// Snapshot encoding
// ---------------------------------------------------------------------------
@ -133,7 +89,6 @@ export function encodeSnapshot(
grid: GridState,
turn: number,
events: EcologicalEvent[] = [],
terrainCache?: Map<string, TerrainData>,
prevStats?: TurnStats,
): GridSnapshot {
const n = grid.tiles.length
@ -163,7 +118,7 @@ export function encodeSnapshot(
texC[base + 3] = tile.habitat_suitability ?? 0.0
}
const stats = computeTurnStats(grid, terrainCache, prevStats)
const stats = computeTurnStats(grid, prevStats)
return {
texA, texB, texC,
@ -183,7 +138,6 @@ const EMPTY_SCHOOL_RECORD: Record<LeySchool, number> = { death: 0, life: 0, natu
export function computeTurnStats(
grid: GridState,
terrainCache?: Map<string, TerrainData>,
prevStats?: TurnStats,
): TurnStats {
const { tiles, width, height } = grid
@ -224,7 +178,7 @@ export function computeTurnStats(
albedoSum += albedo
solarSum += solar * (1.0 - albedo)
aerosolSum += (tile as Record<string, number>).sulfate_aerosol ?? 0
aerosolSum += tile.sulfate_aerosol ?? 0
windSpeedSum += tile.wind_speed ?? 0
if (isWater) {
@ -297,6 +251,7 @@ export function cloneGridState(grid: GridState): GridState {
global_avg_temp: grid.global_avg_temp,
ocean_dead_fraction: grid.ocean_dead_fraction,
ecosystem_health: grid.ecosystem_health,
sea_level: grid.sea_level,
tiles: grid.tiles.map((t) => ({
...t,
river_edges: [...t.river_edges],
@ -371,7 +326,7 @@ export function runScenarioSync(
const events = physics.processStep(grid, turn, worldSeed)
ecology.processStep(grid)
const prev = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined
snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache, prev))
snapshots.push(encodeSnapshot(grid, scenarioTurn, events, prev))
}
return {
@ -409,7 +364,7 @@ export function extendSimulation(
const events = physics.processStep(grid, turn, worldSeed)
ecology.processStep(grid)
const prev = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined
snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache, prev))
snapshots.push(encodeSnapshot(grid, scenarioTurn, events, prev))
}
return {

View file

@ -13,6 +13,7 @@ export interface TileState {
pressure: number // atmospheric pressure (hPa, ~995-1030)
pressure_anomaly: number // dynamic anomaly offset from baseline
humidity: number // atmospheric humidity [0, 1]
sulfate_aerosol: number // stratospheric aerosol opacity [0, 1] from volcanic/impact events
quality: number // 1-5 (Q1 prolific .. Q5 epic)
quality_progress: number // counter toward next quality change
river_edges: number[] // edge indices [0-5] where rivers flow
@ -167,7 +168,7 @@ export interface SimulationResult {
continuation: ContinuationState
}
// Terrain data shape from games/age-of-four/data/terrain/*.json
// Terrain data shape from games/age-of-dwarves/data/terrain/*.json
export interface TerrainData {
id: string
name: string

View file

@ -15,6 +15,7 @@ export interface InitCommand {
type: 'init'
terrainData: Record<string, TerrainData>
params: Record<string, number>
spec?: Record<string, unknown>
}
export interface RunCommand {