refactor(physics): ♻️ Refactor climate physics computation engine in generated files and update supporting runner and type definitions

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 23:53:23 -07:00
parent d8ed38930e
commit 4ec3be52d9
3 changed files with 55 additions and 205 deletions

View file

@ -12,23 +12,18 @@ import { idx, neighbors, upwindPos, solarByRow, classifyTerrain, hashNoise, neig
const CLIMATE_DEFAULTS: Record<string, number> = {
wind_conductivity: 0.1,
energy_scale: 0.01,
equilibrium_relaxation: 0.05,
energy_scale: 0.005,
equilibrium_relaxation: 0.08,
evaporation_rate: 0.05,
moisture_transport: 0.15,
precipitation_threshold: 0.7,
moisture_decay: 0.98,
moisture_relaxation: 0.02,
ocean_evaporation_hops: 3,
moisture_decay: 0.995,
moisture_relaxation: 0.04,
ocean_evaporation_hops: 4,
ocean_evaporation_hop_decay: 0.5,
atmospheric_loss_rate: 0.001,
atmospheric_loss_rate: 0.0003,
quality_up_threshold: 10,
quality_down_threshold: 5,
corruption_spread_rate: 0.02,
corruption_flip_threshold: 0.5,
corruption_decay_rate: 0.004,
corruption_heal_rate: 0.008,
corruption_heal_threshold: 0.15,
lake_thermal_conductivity: 0.05,
river_moisture_transport: 0.075,
mountain_rain_shadow_block: 0.9,
@ -206,8 +201,6 @@ export class ClimatePhysics {
this.stepLakeEvaporation(grid)
this.stepDeepEarthWater(grid)
this.stepPrecipitation(grid)
this.stepTerrainEvolution(grid)
this.stepCorruption(grid)
const events = this.stepEcologicalEvents(grid, turn, seed)
this.stepAnchorDecay(grid)
this.stepGlobalStats(grid)
@ -271,8 +264,8 @@ export class ClimatePhysics {
private stepTemperature(grid: GridState): void {
const { tiles, width: w, height: h } = grid
let conductivity = (this.params as any)["wind_conductivity"] ?? 0.1
let energy_scale = (this.params as any)["energy_scale"] ?? 0.01
let relaxation = (this.params as any)["equilibrium_relaxation"] ?? 0.05
let energy_scale = (this.params as any)["energy_scale"] ?? 0.005
let relaxation = (this.params as any)["equilibrium_relaxation"] ?? 0.08
// Pre-compute solar baseline per row — same value for every tile in a row
for (let row = 0; row < h; row++) {
@ -293,7 +286,7 @@ export class ClimatePhysics {
let solar = solarByRow(row, h)
let current_temp = oldTemp[i]
let terrain_data = (this.terrainCache.get(tile.terrain_id) ?? {})
let terrain_data = (this.terrainCache.get(tile.biome_id) ?? {})
let albedo = (terrain_data as any)["albedo"] ?? 0.4
let net_solar = solar * (1.0 - albedo) * energy_scale
@ -325,7 +318,7 @@ export class ClimatePhysics {
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
const { col, row } = tile
if (tile.terrain_id !== "lake") {
if (tile.biome_id !== "lake") {
continue
}
let lake_temp = tile.temperature
@ -344,10 +337,10 @@ export class ClimatePhysics {
private stepMoistureWind(grid: GridState): void {
const { tiles, width: w, height: h } = grid
let transport_rate = (this.params as any)["moisture_transport"] ?? 0.15
let decay = (this.params as any)["moisture_decay"] ?? 0.98
let decay = (this.params as any)["moisture_decay"] ?? 0.995
let rain_shadow_block = (this.params as any)["mountain_rain_shadow_block"] ?? 0.9
let relaxation = (this.params as any)["moisture_relaxation"] ?? 0.02
let atmo_loss = (this.params as any)["atmospheric_loss_rate"] ?? 0.001
let relaxation = (this.params as any)["moisture_relaxation"] ?? 0.04
let atmo_loss = (this.params as any)["atmospheric_loss_rate"] ?? 0.0003
// Snapshot moisture before decay so all tiles decay from the same baseline
const oldMoisture = new Float32Array(tiles.length)
@ -368,13 +361,13 @@ export class ClimatePhysics {
let transported = 0.0
if (upwind_pos !== null) {
const upwind_tile = tiles[idx(upwind_pos.col, upwind_pos.row, w)]
let upwind_is_mountain = ( upwind_tile !== null && upwind_tile.terrain_id === "mountains" )
let upwind_is_mountain = ( upwind_tile !== null && upwind_tile.biome_id === "mountains" )
let block = (upwind_is_mountain ? rain_shadow_block : 0.0)
transported = ( oldMoisture[idx(upwind_pos.col, upwind_pos.row, w)] * tile.wind_speed * transport_rate * (1.0 - block) )
}
// Evapotranspiration and magic forcing are per-tile local effects — safe to add here
let terrain_data = (this.terrainCache.get(tile.terrain_id) ?? {})
let terrain_data = (this.terrainCache.get(tile.biome_id) ?? {})
let evapotrans = (terrain_data as any)["evapotranspiration"] ?? 0.0
// Moisture equilibrium relaxation — pull toward climate baseline (like temperature)
@ -423,7 +416,7 @@ export class ClimatePhysics {
private stepLakeEvaporation(grid: GridState): void {
const { tiles, width: w, height: h } = grid
let base_evap = (this.params as any)["evaporation_rate"] ?? 0.05
let max_hops = Math.floor( (this.params as any)["ocean_evaporation_hops"] ?? 3 )
let max_hops = Math.floor( (this.params as any)["ocean_evaporation_hops"] ?? 4 )
let hop_decay = (this.params as any)["ocean_evaporation_hop_decay"] ?? 0.5
let rain_shadow_block = (this.params as any)["mountain_rain_shadow_block"] ?? 0.9
// Biological pump failure: dead ocean reduces evaporation → inland forests dry out.
@ -436,7 +429,7 @@ export class ClimatePhysics {
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
const { col, row } = tile
let tid = tile.terrain_id
let tid = tile.biome_id
if (tid !== "lake" && tid !== "ocean" && tid !== "coast") {
continue
@ -468,7 +461,7 @@ export class ClimatePhysics {
if (hop_tile === null) {
break
}
let hop_tid = hop_tile.terrain_id
let hop_tid = hop_tile.biome_id
// Stop if we hit another water tile (no double-injection)
if (hop_tid === "ocean" || hop_tid === "coast" || hop_tid === "lake") {
break
@ -506,7 +499,7 @@ export class ClimatePhysics {
const tile = tiles[i]
const { col, row } = tile
if (tile.terrain_id === "volcano") {
if (tile.biome_id === "volcano") {
tile.moisture = Math.min(1.0, Math.max(0.0, tile.moisture + vol_self))
for (const nb_pos of neighbors(col, row, w, h)) {
const nb = tiles[idx(nb_pos.col, nb_pos.row, w)]
@ -566,170 +559,6 @@ export class ClimatePhysics {
}
}
private stepTerrainEvolution(grid: GridState): void {
const { tiles, width: w, height: h } = grid
let up_thresh = (this.params as any)["quality_up_threshold"] ?? 10
let down_thresh = (this.params as any)["quality_down_threshold"] ?? 5
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
const { col, row } = tile
let tid = tile.terrain_id
// Natural wonders are geological formations — quality evolves but terrain doesn't flip
if (tile.is_natural_wonder) {
continue
}
// Water, corrupted, and fixed-form terrains don't evolve via climate
if (( tid === "ocean" || tid === "coast" || tid === "lake" || tid === "corrupted_land" || tid === "volcano" )) {
continue
}
let ideal = idealTerrain(tile, this.spec)
if (ideal === tid) {
tile.quality_progress += 1
if (tile.quality_progress >= up_thresh) {
tile.quality_progress = 0
if (tile.quality < 5) {
let old_q = tile.quality
tile.quality += 1
} else {
tile.quality_progress -= 1
if (tile.quality_progress <= -down_thresh) {
tile.quality_progress = 0
if (tile.quality > 1) {
let old_q = tile.quality
tile.quality -= 1
} else {
// Quality at floor — terrain flips one step along its chain
let old_type = tid
tile.terrain_id = ideal
tile.quality = 1
tile.quality_progress = 0
}
}
}
}
}
}
}
private stepCorruption(grid: GridState): void {
const { tiles, width: w, height: h } = grid
let spread_rate = (this.params as any)["corruption_spread_rate"] ?? 0.02
let flip_threshold = (this.params as any)["corruption_flip_threshold"] ?? 0.5
let decay_rate = (this.params as any)["corruption_decay_rate"] ?? 0.004
let heal_rate = (this.params as any)["corruption_heal_rate"] ?? 0.008
let heal_threshold = (this.params as any)["corruption_heal_threshold"] ?? 0.15
// Accumulate pressure increments into a separate dict — one O(n) read pass,
// then one O(n) write pass. Avoids double-counting and in-place mutation.
const pressureDeltas = new Float32Array(tiles.length)
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
const { col, row } = tile
if (tile.corruption_pressure <= 0.0) {
continue
}
// terrain_power corruption_spread_modifier on the SOURCE tile scales spread rate.
// e.g. jungle = 0.5x (resists spread), swamp = 1.5x (accelerates spread).
let source_modifier = 1.0
let base_spread = spread_rate * source_modifier
for (const nb_pos of neighbors(col, row, w, h)) {
if (!true) {
continue
}
let nb = tiles[idx(nb_pos.col, nb_pos.row, w)]
if (nb.terrain_id === "corrupted_land") {
continue
}
// Ley line channeling: spread rate is modified by the ley properties of the
// receiving tile. Death ley = 3x, generic ley edge = 2x,
// Nature/Life ley = 0.5x (resists). Off-ley = no channeling modifier.
let ley_mult = leyChannelingMult(nb, this.spec)
// Moisture resistance: high-moisture terrain (jungle, swamp, water) resists
// corruption biologically. Marine life (fish, reefs, algae) can still carry
// corruption through water — so water isn't immune, just dampened.
let moisture_resist = 1.0 - nb.moisture * 0.4
// City protection buildings write corruption_resistance_pct to scale down spread
let corruption_resist = 1.0 - Math.min(1.0, Math.max(0.0, (nb.corruption_resistance_pct ?? 0)))
pressureDeltas[idx(nb_pos.col, nb_pos.row, w)] += base_spread * ley_mult * moisture_resist * corruption_resist
}
}
// Apply pressure deltas + natural decay + Life/Nature ley active healing
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
const { col, row } = tile
let incoming = pressureDeltas[i]
// Natural pressure decay — corruption dissipates without active feeding sources
let drain = decay_rate
// Life/Nature ley lines actively heal (bonus drain, capped at 3 stacks)
if (tile.ley_line_count > 0 && (tile.ley_school === "life" || tile.ley_school === "nature")) {
drain += heal_rate * Math.min(tile.ley_line_count, 3.0)
}
if (incoming === 0.0 && tile.corruption_pressure === 0.0) {
continue
}
tile.corruption_pressure = Math.min(1.0, Math.max(0.0, tile.corruption_pressure + incoming - drain))
}
// Terrain flip: pressure crosses threshold → corrupted_land
// Water tiles (ocean, lake, inland_sea, coast) never flip terrain — corruption
// in water expresses through the marine ecosystem (reef_health, fish_stock) instead.
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
const { col, row } = tile
let tid = tile.terrain_id
if (( tid === "corrupted_land" || tid === "ocean" || tid === "lake" || tid === "inland_sea" || tid === "coast" )) {
continue
}
if (tile.corruption_pressure > flip_threshold) {
if (tile.original_terrain_id === "") {
tile.original_terrain_id = tile.terrain_id
}
tile.terrain_id = "corrupted_land"
}
}
// Marine corruption: coast tiles with elevated pressure degrade reef and fish stock
// (corrupted fish and algae carry corruption through the marine ecosystem)
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
const { col, row } = tile
if (tile.terrain_id !== "coast") {
continue
}
if (tile.corruption_pressure > 0.2) {
tile.reef_health = Math.max(0.0, tile.reef_health - tile.corruption_pressure * 0.015)
if ((tile.fish_stock ?? 0) > 0) {
tile.fish_stock = Math.max(0.0, (tile.fish_stock ?? 0) - tile.corruption_pressure * 0.01)
}
}
}
// Terrain healing: corrupted tile's pressure falls below heal threshold → restore
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
const { col, row } = tile
if (tile.terrain_id !== "corrupted_land" || tile.corruption_pressure > heal_threshold) {
continue
}
let original = tile.original_terrain_id
let restore_to = (original !== "" ? original : "grassland")
tile.terrain_id = restore_to
tile.original_terrain_id = ""
}
}
private stepGlobalStats(grid: GridState): void {
const { tiles, width: w, height: h } = grid
let total = 0.0
@ -737,7 +566,7 @@ export class ClimatePhysics {
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
const { col, row } = tile
if (tile.terrain_id !== "ocean") {
if (tile.biome_id !== "ocean") {
total += tile.temperature
count += 1
}
@ -755,7 +584,7 @@ export class ClimatePhysics {
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
const { col, row } = tile
if (tile.terrain_id !== "coast") {
if (tile.biome_id !== "coast") {
continue
}
coast_count += 1

View file

@ -20,7 +20,7 @@ import { WORLD_SEED, DEFAULT_SCENARIO_TURNS } from './configs'
export const TERRAIN_ORDER: readonly string[] = [
'ocean', 'coast', 'lake', 'inland_sea', 'ice', 'snow', 'tundra', 'desert',
'plains', 'grassland', 'forest', 'boreal_forest', 'jungle', 'enchanted_forest',
'hills', 'mountains', 'swamp', 'corrupted_land', 'volcano',
'hills', 'mountains', 'swamp', 'volcano',
// Natural wonders (geological/biological formations)
'mana_node', 'ley_nexus', 'lodestone_spire', 'crystal_cavern',
'worldroot', 'primordial_spring', 'abyssal_vortex',
@ -91,6 +91,7 @@ export function encodeSnapshot(
const n = grid.tiles.length
const texA = new Float32Array(n * 4)
const texB = new Float32Array(n * 4)
const texC = new Float32Array(n * 4)
for (let i = 0; i < n; i++) {
const tile = grid.tiles[i]
@ -98,7 +99,7 @@ export function encodeSnapshot(
texA[base + 0] = tile.temperature
texA[base + 1] = tile.moisture
texA[base + 2] = tile.corruption_pressure
texA[base + 2] = tile.canopy_cover ?? 0.0
texA[base + 3] = tile.reef_health
texB[base + 0] = tile.wind_direction / 5
@ -107,12 +108,17 @@ export function encodeSnapshot(
let riverMask = 0
for (const e of tile.river_edges) riverMask |= (1 << e)
texB[base + 3] = riverMask / 63
texC[base + 0] = tile.undergrowth ?? 0.0
texC[base + 1] = tile.fungi_network ?? 0.0
texC[base + 2] = (tile.quality ?? 1) / 5.0
texC[base + 3] = tile.habitat_suitability ?? 0.0
}
const stats = computeTurnStats(grid)
return {
texA, texB,
texA, texB, texC,
width: grid.width,
height: grid.height,
turn,
@ -132,7 +138,6 @@ export function computeTurnStats(grid: GridState): TurnStats {
let tempSum = 0
let moistSum = 0
let landCount = 0
let corruptedCount = 0
const terrain_counts: Record<string, number> = {}
for (const tile of tiles) {
@ -143,14 +148,12 @@ export function computeTurnStats(grid: GridState): TurnStats {
landCount++
tempSum += tile.temperature
moistSum += tile.moisture
if (tile.terrain_id === 'corrupted_land') corruptedCount++
}
}
return {
avg_temp: landCount > 0 ? tempSum / landCount : 0.5,
avg_moisture: landCount > 0 ? moistSum / landCount : 0.5,
corrupted_pct: landCount > 0 ? corruptedCount / landCount : 0,
total_ley_strength: 0,
dominant_ley_school: '',
ley_school_strengths: { ...EMPTY_SCHOOL_RECORD },
@ -178,9 +181,11 @@ export function cloneGridState(grid: GridState): GridState {
height: grid.height,
global_avg_temp: grid.global_avg_temp,
ocean_dead_fraction: grid.ocean_dead_fraction,
ecosystem_health: grid.ecosystem_health,
tiles: grid.tiles.map((t) => ({
...t,
river_edges: [...t.river_edges],
wonder_anchor_schools: [...t.wonder_anchor_schools],
})),
}
}

View file

@ -8,13 +8,13 @@ export interface TileState {
moisture: number // [0, 1]
elevation: number // [0, 1]
terrain_id: string
biome_id: string // computed biome from substrate + climate + flora
wind_direction: number // [0, 5] axial direction index
wind_speed: number // [0, 1]
quality: number // 1 | 2 | 3
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
flow_accumulation: number
corruption_pressure: number // [0, 1]
original_terrain_id: string
ley_line_count: number
ley_school: LeySchool | ''
@ -26,10 +26,24 @@ export interface TileState {
wonder_anchor_school: School // LEGACY — single school (kept for anchor decay compat)
wonder_anchor_schools: LeySchool[] // multi-school affinities for ley network
wonder_tier: number // 1-5, aligned with eras (separate from terrain quality)
// Substrate fields (set at map gen, rarely change)
substrate_id: string // geological substrate from elevation
water_body_id: number // water body index (-1 if land)
depth_from_coast: number // BFS distance from land (-1 if land)
// Flora fields (updated per turn by ecology system)
canopy_cover: number // [0, 1] forest canopy density
undergrowth: number // [0, 1] ground vegetation density
fungi_network: number // [0, 1] mycorrhizal network density
drought_counter: number // turns of consecutive low moisture
succession_progress: number // turns of stable high canopy
regrowth_stage: number // -1 = none, 0-3 = barren→forest
regrowth_turns: number // turns in current regrowth stage
// Fauna fields (updated per turn by ecology system)
habitat_suitability: number // [0, 1] weighted flora average
landmark_name: string // Q4+ tile name from FlavorGenerator
// Optional fields written by subsystems (not present on all tiles)
river_source_type?: string // 'snowmelt' | 'spring' | 'hot_spring' | 'glacial' | undefined
fish_stock?: number // marine ecosystem fish population [0, 1]
corruption_resistance_pct?: number // city building protection [0, 1]
}
export interface GridState {
@ -38,12 +52,12 @@ export interface GridState {
height: number
global_avg_temp: number
ocean_dead_fraction: number
ecosystem_health: number // [0, 1] global ecology health
}
export interface TurnStats {
avg_temp: number
avg_moisture: number
corrupted_pct: number
total_ley_strength: number
dominant_ley_school: LeySchool | ''
ley_school_strengths: Record<LeySchool, number>
@ -52,12 +66,14 @@ export interface TurnStats {
terrain_counts: Record<string, number>
}
// Packed per-tile data for rendering: 2 RGBA float textures
// texA: [temperature, moisture, corruption_pressure, reef_health]
// Packed per-tile data for rendering: 3 RGBA float textures
// texA: [temperature, moisture, canopy_cover, reef_health]
// texB: [wind_direction/5, wind_speed, terrain_encoded, river_bitmask/63]
// texC: [undergrowth, fungi_network, quality/5, habitat_suitability]
export interface GridSnapshot {
texA: Float32Array // width * height * 4
texB: Float32Array // width * height * 4
texC: Float32Array // width * height * 4 (ecology)
width: number
height: number
turn: number
@ -134,7 +150,7 @@ export interface TerrainData {
color: [number, number, number]
flags: string[]
climate_zone: string
terrain_power?: { spell_school?: string; corruption_spread_modifier?: number }
terrain_power?: { spell_school?: string }
mana_major?: { school: string; amount: number }
ley_anchor_strength?: number
ley_anchor_school?: string