Files
last-hearth/mods/hearth/succession.lua
T
2026-11-03 12:41:09 -05:00

759 lines
37 KiB
Lua
Executable File

-- Original loaded-only succession. No ABMs, map emergence, timers or world registry.
return function(hearth, api)
local S = {BUDGET = 8, RADIUS = 12, INTERVAL = 1, MAX_GAP = 1200, CACHE_LIMIT = 8192,
MAX_TREE_CELLS=810, MAX_WATER_DEPTH=6}
local PREFIX, PERIOD = "hearth:", 60
local kinds = {meadow_grass=true, wildflowers=true, fern=true, litter=true, fungi=true,
deadwood=true, young_tree=true, mature_tree=true, tree_seedling=true, dung=true,
spring_greens_patch=true, autumn_mushrooms_patch=true}
local trees = {tree_seedling=true, young_tree=true, mature_tree=true}
local diversity = hearth.diversity
local botany = hearth.botany
local effects = hearth.species_effects
-- Content may inject the factory before succession. Standalone integrations
-- without it keep their old API; the engine loads only this fixed sibling.
if botany and not effects and api.get_modpath then
effects=dofile(api.get_modpath("hearth").."/species_effects.lua")(hearth,api)
hearth.species_effects=effects
end
if botany then
for _,r in ipairs(botany.list()) do
if r.group~="trees" then kinds[r.id]=true end
end
end
if diversity then
for _,k in ipairs(diversity.plants) do kinds[k]=true end
for _,species in ipairs(diversity.species_order) do
for _,stage in ipairs({"seedling","young","mature"}) do
local k=species.."_"..stage; kinds[k]=true; trees[k]=true
end
end
end
local offsets = {{1,0}, {0,1}, {-1,0}, {0,-1}, {2,1}, {-2,-1}, {1,-2}, {-1,2}}
local seen, ring, ring_index, cursor, player_index, elapsed = {}, {}, 1, 0, 0, 0
local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end
local function at(p, x, y, z) return {x=p.x+x, y=p.y+y, z=p.z+z} end
local function position(p)
if type(p) ~= "table" then return nil end
for _, axis in ipairs({"x", "y", "z"}) do
local n = p[axis]
if type(n) ~= "number" or n ~= n or math.abs(n) > 30990 then return nil end
end
return {x=math.floor(p.x+0.5), y=math.floor(p.y+0.5), z=math.floor(p.z+0.5)}
end
local function node(p)
local n = api.get_node_or_nil(p)
if n and n.name ~= "ignore" then return n end
end
local function kind(n)
local name=type(n)=="table" and n.name or n
if type(name)~="string" then return nil end
return hearth.seasons and hearth.seasons.kind(name) or name:match("^hearth:(.+)$")
end
local function family(k) return diversity and diversity.family(k) or k end
local function number(meta, field, maximum)
local n = tonumber(meta:get_string(field))
if not n or n ~= n or n < 0 or n == math.huge then return 0 end
return math.min(maximum or 1000000000000, n)
end
local markers = {"owner", "planter", "placed_by", "player_placed", "hearth_placed", "hearth_player_placed", "planted_at"}
local function marked(p)
local meta = api.get_meta(p)
-- Unknown third-party placement/provenance metadata is not ours to erase.
-- Keep the named fallback for minimal API mocks and older integrations.
if meta.to_table then
local value = meta:to_table()
for field, text in pairs(value and value.fields or {}) do
local harvest=botany and field=="botany_ready_at" and tonumber(text)
and tonumber(text)>=0 and tonumber(text)<math.huge
if field:sub(1,5) ~= "succ_" and not harvest and text ~= "" and tostring(text) ~= "0" then return true end
end
end
for _, field in ipairs(markers) do
local value = meta:get_string(field)
if value ~= "" and value ~= "0" then return true end
end
return false
end
local function permitted(p)
-- Autonomous nature has no claim-owner identity, including on shared claims.
if hearth.claim_owner_at then
local owner, ambiguous = hearth.claim_owner_at(p)
if owner or ambiguous then return false end
end
-- Player access deliberately rejects empty names, even on unclaimed ground.
-- Nature must instead explicitly exclude claims and external protection.
local protection = hearth.external_protected or api.is_protected
return not (protection and protection(p, "")) and not marked(p)
end
local function ground(p)
local n = node(p)
return n and (n.name == PREFIX.."grass" or n.name == PREFIX.."soil"
or (diversity and (diversity.is_ground(n) or diversity.is_rock(n))))
end
local function natural(p, n)
return n and (kinds[kind(n)] or (botany and api.get_meta(p):get_string("succ_component")~=""))
and api.get_meta(p):get_string("succ_natural") == "1"
end
-- Semantic substrate observations never replace water, logs or terrain.
-- A water stand occupies AIR above a separately conserved source column.
local function substrate(p)
local below=at(p,0,-1,0)
local n=node(below)
if not n then return nil end
local k=kind(n)
if ground(below) then return {ground=k,substrate=k,water=0,base=below} end
if not botany then return nil end
if k=="water_source" then
for depth=1,S.MAX_WATER_DEPTH do
local q=at(p,0,-depth,0)
local w=node(q)
if not w or kind(w)~="water_source" or not permitted(q) then return nil end
local bottom=at(p,0,-depth-1,0)
local b=node(bottom)
if not b or not permitted(bottom) then return nil end
if kind(b)~="water_source" then
if not ground(bottom) and kind(b)~="sand" then return nil end
return {ground=kind(b),substrate="water",water=depth,base=bottom,freshwater=true}
end
end
elseif natural(below,n) and permitted(below) then
if k=="deadwood" or k=="litter" then
return {ground=k,substrate=k,water=0,base=below,host=api.get_meta(below):get_string("succ_species")}
elseif family(k)=="log" or family(k)=="young_tree" or family(k)=="mature_tree" then
local id=botany.kind(n) or api.get_meta(below):get_string("succ_species")
return {ground="bark",substrate="bark",water=0,base=below,host=id}
end
end
end
local function site(p)
local n, below = node(p), at(p,0,-1,0)
if not n or not substrate(p) or not permitted(p) or not permitted(below) then return false end
if n.name ~= "air" and not natural(p, n) then return false end
if not botany and diversity and n.name~="air" and not diversity.supports_ground(kind(n),node(below)) then return false end
-- A conservative buffer around farms/builds/markers; unknown/unloaded fails closed.
for x=-1,1 do for z=-1,1 do for y=0,2 do
local q = at(p,x,y,z)
local other = node(q)
if not other or marked(q) then return false end
local name = family(kind(other))
if other.name ~= "air" and not natural(q, other)
and name ~= "leaves" and name ~= "log"
and not (botany and kind(other)=="water_source" and permitted(q))
and not (diversity and diversity.is_worldgen_flora(other) and permitted(q)) then return false end
end end end
return true
end
function S.habitat(raw)
local p = position(raw)
local h = {grass=0, flowers=0, cover=0, water=0, canopy=0, loaded=true, moisture=0, light=0,
edible=0,pollination=0,species_counts={}}
if not p then h.loaded=false; return h end
local support=node(at(p,0,-1,0))
h.ground=kind(support)
local sub=substrate(p)
if botany and not sub then h.loaded=false; return h end
if botany then
h.ground,h.substrate,h.water,h.host=sub.ground,sub.substrate,sub.water,sub.host
h.freshwater=sub.freshwater==true
h.aquatic="land"; h.near_water=0; h.nursery=0; h.root_fungi={}
-- Support-class pH is a deliberately coarse game observation.
h.ph=({peat=4.5,podzol=5,alpine_turf=5.5,granite=5,basalt=6.5,
limestone=7.5,slate=5.5,sandstone=5.5,scree=5.5,stone=5.5,
forest_floor=5.5,alluvium=6.5,grass=6.5,soil=6.5,sand=6.5,bark=5.5,deadwood=5.5,litter=5.5})[h.ground]
end
h.nutrients=number(api.get_meta(at(p,0,-1,0)),"succ_nutrients",8)
-- Leafless deciduous branches cast less shade than a summer canopy.
local season=hearth.seasons and hearth.seasons.season() or "Summer"
local foliage = season == "Winter" and 0.15 or 1
-- Fixed 5x5x5 stencil: includes river water, vegetation and overhead canopy.
for x=-2,2 do for z=-2,2 do for y=-1,3 do
local q = at(p,x,y,z)
local n = node(q)
if not n then h.loaded=false
else
local k = kind(n)
local f=family(k)
local evergreen=k=="pine_leaves" or k=="spruce_leaves"
or (diversity and (diversity.tree_info(k)=="pine" or diversity.tree_info(k)=="spruce"))
local shade=evergreen and 1 or foliage
if k == "water_source" or k == "water_flowing" then
if botany then h.near_water=h.near_water+1 else h.water=h.water+1 end
end
-- A tree's own crown shades its neighbours, not its root's
-- establishment test. Otherwise every grown tree self-starves.
if f == "leaves" and not (botany and api.get_meta(q):get_string("succ_component")==key(p)) then h.canopy=h.canopy+0.15*shade end
if natural(q,n) then
local owner_root=api.get_meta(q):get_string("succ_component")
local component=owner_root~="" and owner_root~=key(q)
if botany and permitted(q) then
local r=botany.get(botany.kind(n))
if r then
-- One rooted mat, not every tree component or stacked sprite.
-- Use the existing stencil; never a new radius scan.
if y==0 and not component and botany.active(r.id,season) then
local below=at(q,0,-1,0)
local b=node(below)
local supported=(r.id=="sphagnum" and b and (kind(b)=="peat" or kind(b)=="alluvium"))
or (r.id=="duckweed" and b and kind(b)=="water_source")
or (r.id=="alder" and trees[k] and ground(below))
if supported and permitted(below) then
h.species_counts[r.id]=(h.species_counts[r.id] or 0)+1
end
end
if not h.host and r.group=="trees" then h.host=r.id end
if r.group=="grasses" then h.grass=h.grass+1 end
if botany.pollinator(k,season) and r.group~="trees" then h.flowers=h.flowers+1 end
if r.group=="shrubs" or r.group=="ferns_mosses" or r.group=="aquatic" then h.cover=h.cover+1 end
if botany.browse(k,season) and not component then h.edible=h.edible+1 end
if botany.pollinator(k,season) and not component then h.pollination=h.pollination+1 end
if botany.active(k,season) and r.tags.nursery and not component then h.nursery=h.nursery+1 end
if r.group=="fungi" and r.tags.ectomycorrhiza and botany.active(k,season) then h.root_fungi[#h.root_fungi+1]=r.id end
end
end
if k == "meadow_grass" then h.grass=h.grass+1 end
if k == "wildflowers" then h.flowers=h.flowers+1 end
if k == "fern" or trees[k] or (diversity and diversity.is_worldgen_flora(k)) then h.cover=h.cover+1 end
if f == "mature_tree" and api.get_meta(q):get_string("succ_tree_stage")=="" then h.canopy=h.canopy+shade end
if f == "young_tree" and api.get_meta(q):get_string("succ_tree_stage")=="" then h.canopy=h.canopy+0.5*shade end
if diversity and not botany and diversity.edible(k) then h.edible=h.edible+1 end
if diversity and (not botany or not botany.kind(k)) and diversity.pollinator(k,season) then h.pollination=h.pollination+1 end
end
end
end end end
local light = api.get_node_light and api.get_node_light(p, 0.5)
h.light = math.max(0, math.min(15, light or 15) - h.canopy*3)
local biome = api.get_biome_data and api.get_biome_data(p)
h.heat=biome and biome.heat or 50
h.moisture = math.min(1, (biome and biome.humidity or 45)/100 + math.min(0.5,(h.near_water or h.water)*0.1) + h.canopy*0.08)
local surfaces=hearth.biomes and hearth.biomes.surface
local surface=surfaces and surfaces(support)
local soil_moisture=surface and surface.moisture
or ({forest_floor=0.6,podzol=0.4,alluvium=0.85,peat=1,alpine_turf=0.35})[h.ground]
if soil_moisture then h.moisture=math.min(1,(h.moisture+soil_moisture)/2) end
if botany then
if h.water>0 then
local r=botany.get(node(p))
h.aquatic=r and r.niche.aquatic or ((math.abs(p.x*17+p.z*31)%2==0) and "surface" or "submerged")
h.moisture=1
elseif h.near_water>0 and h.moisture>=0.65 then h.aquatic="bank" end
end
return effects and effects.habitat(h,h.species_counts) or h
end
local function active(p, now)
local k = key(p)
local previous = seen[k]
if not previous then
local evicted = ring[ring_index]
if evicted then seen[evicted]=nil end
ring[ring_index]=k; ring_index=ring_index%S.CACHE_LIMIT+1
end
seen[k]=now
-- Restart, cache eviction, clock rewind and long absences only rebaseline.
if not previous or now <= previous or now-previous > S.MAX_GAP then return 0 end
return now-previous
end
local function tree_clear(p)
-- Species mature geometry reaches five nodes/±1.5. Conservative
-- 5x5x6 target volume, checked loaded/protected/marked, never emerged.
for x=-2,2 do for z=-2,2 do for y=0,5 do
local q = at(p,x,y,z)
local n = node(q)
if not n or not permitted(q) then return false end
if not (x==0 and y==0 and z==0) and n.name ~= "air" then
if not natural(q,n) or trees[kind(n)] then return false end
end
end end end
return true
end
local tree_epoch
local function plan_for(species,stage)
local plan=diversity and diversity.tree_plan and diversity.tree_plan(species,stage)
if type(plan)~="table" or #plan==0 or #plan>S.MAX_TREE_CELLS then return nil end
local root,seen_cells=false,{}
for _,c in ipairs(plan) do
if type(c)~="table" then return nil end
for _,axis in ipairs({"x","y","z"}) do
local v=c[axis]
if type(v)~="number" or v~=v or v%1~=0 or math.abs(v)>(axis=="y" and 9 or 4) or (axis=="y" and v<0) then return nil end
end
local k=kind(c.name)
local f=family(k)
if f~="log" and f~="leaves" and f~="young_tree" and f~="mature_tree" then return nil end
local rootkind=species=="generic" and (stage=="young" and "young_tree" or "mature_tree") or species.."_"..stage
local wood=species=="generic" and "log" or species.."_log"
local leaves=species=="generic" and "leaves" or species.."_leaves"
if k~=rootkind and k~=wood and k~=leaves then return nil end
local rotation=c.param2 or 0
if type(rotation)~="number" or rotation~=rotation or rotation%1~=0 or rotation<0 or rotation>23 then return nil end
if c.name:sub(1,7)~=PREFIX or seen_cells[key(c)] then return nil end
seen_cells[key(c)]=true
if c.x==0 and c.y==0 and c.z==0 then root=true end
end
return root and plan or nil
end
local function owned(p,root,expected)
local n=node(p)
local m=api.get_meta(p)
return n and natural(p,n) and m:get_string("succ_component")==root
and kind(n)==kind(expected.name) and (n.param2 or 0)==(expected.param2 or 0)
end
local function components(p,species,stage)
local plan=plan_for(species,stage)
if not plan then return nil end
local cells={}
for _,c in ipairs(plan) do cells[key(at(p,c.x,c.y,c.z))]=c end
return cells,plan
end
local function clear_component(q)
api.swap_node(q,{name="air"})
local m=api.get_meta(q)
for _,f in ipairs({"succ_natural","succ_component","succ_species","succ_age","succ_stress"}) do m:set_string(f,"") end
end
local function tree_transition(p,target,removed)
-- Reserve before generating even the read-only geometry, so eight
-- competing roots cannot each construct a full plan in one second.
local epoch=os.time()
if tree_epoch==epoch then return false end
tree_epoch=epoch
local m=api.get_meta(p)
local current=removed or kind(node(p))
local species,stage=diversity.tree_info(current)
species=species or "generic"
stage=stage or ({young_tree="young",mature_tree="mature"})[current]
local _,next_stage=diversity.tree_info(target)
next_stage=next_stage or ({young_tree="young",mature_tree="mature"})[target]
local previous=m:get_string("succ_tree_stage")
if removed then previous=stage end
if previous=="" then previous=nil end
local old,oldplan={},{}
if previous then
old,oldplan=components(p,species,previous)
if not old then return false end
end
local plan={}
if next_stage then plan=plan_for(species,next_stage); if not plan then return false end end
local root=key(p)
local destinations,extras={},{}
for _,c in ipairs(oldplan) do destinations[key(at(p,c.x,c.y,c.z))]={c=c,old=true} end
for _,c in ipairs(plan) do
local id=key(at(p,c.x,c.y,c.z))
destinations[id]=destinations[id] or {}; destinations[id].next=c
end
-- One full plan preflight/application per wall-clock second globally,
-- including failed attempts. Never multiply tree work by player count.
for id,cell in pairs(destinations) do
local c=cell.c or cell.next
local q=at(p,c.x,c.y,c.z)
local n=node(q)
local support=at(q,0,-1,0)
if not position(q) or not n or not node(support) or not permitted(q) or not permitted(support) then return false end
if id~=root then
if cell.old then
if n.name~="air" and not owned(q,root,old[id]) then return false end
elseif n.name~="air" then return false end
elseif removed then
if n.name~="air" then return false end
elseif not natural(q,n) then return false end
-- Epiphytes are not tree components. Refuse growth into them; on
-- death remove only explicitly natural occupants losing their wood.
if not next_stage and cell.old and family(kind(c.name))=="log" then
local above=at(q,0,1,0)
local a=node(above)
if not a or not permitted(above) then return false end
if a.name~="air" and not destinations[key(above)] and not (botany and botany.kind(a) and natural(above,a)) then return false end
if botany and botany.kind(a) and natural(above,a) and not destinations[key(above)] then extras[key(above)]=above end
end
end
-- All destinations passed before the first write. Lua callbacks execute
-- synchronously; no yielding, inventory callbacks or map emergence here.
for _,q in pairs(extras) do clear_component(q) end
for id,cell in pairs(destinations) do
local c=cell.c or cell.next
local q=at(p,c.x,c.y,c.z)
if id~=root then
if cell.next then
local nextcell=cell.next
local name=hearth.seasons and hearth.seasons.name_for(kind(nextcell.name)) or nextcell.name
api.swap_node(q,{name=name,param2=nextcell.param2 or 0})
local cm=api.get_meta(q)
cm:set_string("succ_natural","1"); cm:set_string("succ_component",root)
cm:set_string("succ_species",species)
elseif node(q).name~="air" then clear_component(q) end
end
end
m:set_string("succ_tree_stage",next_stage or "")
if removed then
m:set_string("succ_component",""); m:set_string("succ_natural","")
if hearth.invalidate_environment then hearth.invalidate_environment(p) end
return true
end
m:set_string("succ_component",next_stage and root or "")
return true
end
local function change(p, target)
-- All callers preflight; recheck source and natural support immediately before writing.
if not site(p) then return false end
local h=botany and S.habitat(p)
if botany then
if not h.loaded then return false end
if target and botany.kind(target) then
if not botany.suitable(target,h,hearth.seasons and hearth.seasons.season() or "Summer") then return false end
elseif target and (h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter") then return false end
elseif diversity and target and not diversity.supports_ground(target,node(at(p,0,-1,0))) then return false end
local expanding=family(target)=="young_tree" or family(target)=="mature_tree"
local managed=api.get_meta(p):get_string("succ_tree_stage")~=""
if diversity and (expanding or managed) and (botany or managed or hearth.biomes) then
if not tree_transition(p,target) then return false end
elseif expanding and not tree_clear(p) then return false end
-- A wood/litter substrate can carry one natural fungus/epiphyte above
-- it. Do not leave that occupant hanging when its substrate is spent.
if botany and (kind(node(p))=="deadwood" or kind(node(p))=="litter") then
local above=at(p,0,1,0)
local a=node(above)
if not a or not permitted(above) then return false end
if a.name~="air" then
if not natural(above,a) or not botany.kind(a) then return false end
clear_component(above)
end
end
local origin=diversity and diversity.tree_info(kind(node(p)))
local name = target and (hearth.seasons and hearth.seasons.name_for(target) or PREFIX..target) or "air"
api.swap_node(p, {name=name,param2=target=="deadwood" and 12 or 0})
local m = api.get_meta(p)
local species=diversity and diversity.tree_info(target)
if species or origin then m:set_string("succ_species",species or origin)
elseif target~="deadwood" and target~="litter" then m:set_string("succ_species","") end
m:set_string("succ_natural", target and "1" or "")
m:set_string("succ_age", "0"); m:set_string("succ_stress", "0")
if hearth.invalidate_environment then hearth.invalidate_environment(p) end
return true
end
-- Optional after-dig handoff. Root identity comes from the old node, never
-- executable serialized metadata. Only this root's matching components go.
function S.tree_removed(raw,oldnode)
local p=position(raw)
if not p or not diversity or not oldnode then return false end
local k=kind(oldnode)
local f=family(k)
if f~="young_tree" and f~="mature_tree" then return false end
if not node(p) or node(p).name~="air" or not permitted(p) or not permitted(at(p,0,-1,0)) then return false end
return tree_transition(p,nil,k)
end
if api.register_on_dignode then api.register_on_dignode(function(p,oldnode) S.tree_removed(p,oldnode) end) end
local function nutrients(p, amount)
local below = at(p,0,-1,0)
if not ground(below) or not permitted(below) then return end
if diversity and not diversity.is_ground(node(below)) then return end
local m = api.get_meta(below)
m:set_string("succ_nutrients", tostring(math.max(0,math.min(8,number(m,"succ_nutrients",8)+amount))))
return true
end
local function disperse(p, target, serial)
local d = offsets[serial%#offsets+1]
local q = at(p,d[1],0,d[2])
if not site(p) or not site(q) or node(q).name ~= "air" then return false end
local dest = S.habitat(q)
if not dest.loaded then return false end
if botany and botany.kind(target) then
if not botany.suitable(target,dest,hearth.seasons and hearth.seasons.season() or "Summer") then return false end
elseif diversity and not diversity.suitable(target,dest) then return false end
if family(target) == "tree_seedling" and (dest.canopy >= 1.5 or dest.grass+dest.flowers >= 15) then return false end
if target == "meadow_grass" and (dest.light < 9 or dest.moisture>=0.75) then return false end
if target == "wildflowers" and (dest.light < 11 or dest.grass >= 10) then return false end
if target == "fern" and (dest.light > 12 or dest.moisture < 0.55) then return false end
if target == "spring_greens_patch" and (dest.light < 9 or dest.moisture < 0.55) then return false end
if target == "autumn_mushrooms_patch" and (dest.light > 10 or dest.moisture < 0.6) then return false end
if hearth.seasons and not hearth.seasons.available(target) then return false end
local m = api.get_meta(q)
-- Destination cooldown survives grazing, decay and restart; no repeated seed rerolls.
local now = os.time()
if number(m,"succ_next") > now then return false end
if not change(q,target) then return false end
m:set_string("succ_next", tostring(now+PERIOD))
nutrients(q,-1)
return true
end
local function observe(p, now)
if not site(p) then seen[key(p)]=nil; return false end
local component=api.get_meta(p):get_string("succ_component")
if component~="" and component~=key(p) then return false end
local dt = active(p,now)
local m, n = api.get_meta(p), node(p)
if dt == 0 then return false end
local h = S.habitat(p)
if not h.loaded then seen[key(p)]=nil; return false end
local k = kind(n)
local f=family(k)
local species=diversity and diversity.tree_info(k)
local record=botany and botany.get(k)
local season=hearth.seasons and hearth.seasons.season(now) or "Summer"
local climate = hearth.climate_at and hearth.climate_at(now)
local warm = not climate or (climate.temperature or 12) >= 3
local decomposer = k=="dung" or k=="litter" or k=="deadwood" or k=="fungi"
local dormant = (record and not botany.active(k,season)) or (hearth.seasons and not hearth.seasons.available(k,now))
local age_dt = (not decomposer and (not warm or dormant)) and 0 or dt
local age = number(m,"succ_age",1000000)+age_dt
m:set_string("succ_age", tostring(age))
if number(m,"succ_next") > now then return false end
-- Commit the attempt BEFORE evaluating it, including failed competition/dispersal.
m:set_string("succ_next", tostring(now+PERIOD))
local serial = number(m,"succ_serial",1000000000)+1
m:set_string("succ_serial", tostring(serial))
local fertility = number(api.get_meta(at(p,0,-1,0)),"succ_nutrients",8)
if n.name == "air" then
if warm and (botany or not hearth.seasons or hearth.seasons.season(now)~="Winter")
and age >= (fertility>0 and 90 or 180) and h.light >= (botany and 2 or 5) then
local target = h.light<10 and h.moisture>=0.55 and "fern" or "meadow_grass"
if h.light>=11 and h.grass>=2 and h.grass<10 and (p.x+p.z)%4==0 then target="wildflowers" end
if hearth.seasons then
if hearth.seasons.season(now)=="Autumn" and target=="wildflowers" then target="meadow_grass" end
if hearth.seasons.available("spring_greens_patch",now) and h.light>=9 and h.moisture>=0.55
and (p.x*3+p.z*5)%7==0 then target="spring_greens_patch" end
if hearth.seasons.available("autumn_mushrooms_patch",now) and h.light<=10 and h.moisture>=0.6
and (p.x+p.z)%5==0 then target="autumn_mushrooms_patch" end
end
if botany then
-- No optimistic meadow fallback for bark, saturated banks,
-- water or a missing botanical niche. Use the actual returned
-- node kind, never the descriptor ID (trees have stage IDs).
h.include_trees=age>=7200 and h.canopy<1.5 and (p.x*7+p.z*11)%23==0
target=botany.choose(p,h,season)
if not target then return false end
elseif diversity then
target=diversity.choose_recruit(p,h,hearth.seasons and hearth.seasons.season(now) or "Summer") or target
-- Saturated riparian soils cannot silently fall back to meadow;
-- exposed bedrock admits lichen only, never soil replacement.
if not diversity.supports_ground(target,node(at(p,0,-1,0)))
or (target=="meadow_grass" and h.moisture>=0.75) then return false end
end
if h.light<9 and target=="meadow_grass" then return false end
if change(p,target) then nutrients(p,-1); return true end
end
return false
end
-- Perennials rest rather than die at first frost. Autumn litter keeps
-- entering the nutrient cycle in winter; decomposition remains active.
if not decomposer and (not warm or dormant) then
if f=="mature_tree" and serial%5==0 then disperse(p,"litter",serial) end
return false
end
local harsh = (k=="meadow_grass" and h.light<8) or (k=="wildflowers" and (h.light<10 or h.grass>=12))
or (k=="fern" and (h.moisture<0.5 or h.light>13)) or (trees[k] and (h.moisture<0.25 or h.light<4))
if record then
if not botany.suitable(k,h,season) then harsh=true end
elseif diversity and diversity.is_worldgen_flora(k) and not diversity.suitable(k,h) then harsh=true end
local stress_dt=botany and h.nursery>0 and dt*0.75 or dt
local stress = harsh and number(m,"succ_stress",100000)+stress_dt or 0
m:set_string("succ_stress",tostring(stress))
if stress>=600 then
local remains=trees[k] and "deadwood" or "litter"
if botany and ((record and record.group=="fungi") or h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter") then remains=nil end
if diversity and diversity.is_rock(node(at(p,0,-1,0))) then remains=nil end
return change(p,remains)
end
if decomposer then
local lifetime = ({dung=600,litter=1800,deadwood=7200,fungi=2400})[k]
if age>=lifetime then
local target = k=="deadwood" and "litter" or nil
if change(p,target) then nutrients(p,k=="dung" and 4 or (k=="deadwood" and 3 or 1)); return true end
elseif k~="fungi" and h.moisture>=0.55 and h.light<=12 and age>=300 then
disperse(p,"fungi",serial)
end
return false
end
if not warm then return false end
if record and age>=900 and record.tags.nitrogenfixer then
local below=at(p,0,-1,0)
local soil=api.get_meta(below)
local fixed=number(soil,"succ_fixed_nitrogen",2)
local delta=math.min(botany.nitrogen(k,fixed),math.max(0,4-fertility))
if delta>0 and fertility<4 and permitted(p) and permitted(below) and nutrients(p,delta) then
soil:set_string("succ_fixed_nitrogen",tostring(fixed+delta))
end
end
local seed_age=21600
local exchange=0
if botany and species and f=="tree_seedling" then
for _,fungus in ipairs(h.root_fungi) do exchange=math.max(exchange,botany.root_fungi(fungus,species,fertility)) end
if exchange>0 then seed_age=16200 end
end
if f=="tree_seedling" and age>=seed_age and h.canopy<1.5 then
if change(p,species and species.."_young" or "young_tree") then
if exchange>0 then nutrients(p,-exchange) end
return true
end
return false
end
if f=="young_tree" and age>=86400 and h.canopy<2 then return change(p,species and species.."_mature" or "mature_tree") end
if f=="mature_tree" then
if age>=259200 then return change(p,"deadwood") end
if age>=1800 and serial%10==0 then disperse(p,species and species.."_seedling" or "tree_seedling",serial) end
if serial%5==0 then disperse(p,"litter",serial) end
elseif record and not trees[k] then
local lifetime=({shrubs=86400,grasses=21600,herbs=14400,ferns_mosses=43200,fungi=2400,aquatic=21600,lichens=86400})[record.group]
if record.tags.saprotroph and (h.substrate=="deadwood" or h.substrate=="litter")
and botany.suitable(k,h,season) then
-- Advance only an existing natural substrate's finite clock;
-- never grant the fungus a second nutrient payout of its own.
local below=at(p,0,-1,0)
if natural(below,node(below)) and permitted(below) then
local wood=api.get_meta(below)
local limit=h.substrate=="deadwood" and 7200 or 1800
wood:set_string("succ_age",tostring(math.min(limit,number(wood,"succ_age",limit)+math.min(dt,PERIOD))))
end
end
if age>=lifetime then
local remains="litter"
if record.group=="fungi" or h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter"
or (diversity and diversity.is_rock(node(at(p,0,-1,0)))) then remains=nil end
return change(p,remains)
end
if age>=7200 and h.water==0 and ground(at(p,0,-1,0)) and h.canopy<1.5 and (p.x*7+p.z*11)%23==0 then
local th={}; for field,value in pairs(h) do th[field]=value end; th.include_trees=true
-- The same catalogue selector is the source of all ten trees;
-- skip non-tree results without changing its deterministic order.
local recruit=botany.choose(p,th,season)
if recruit and trees[recruit] then return change(p,recruit) end
end
if age>=900 and serial%3==0 then disperse(p,k,serial) end
elseif diversity and diversity.is_worldgen_flora(k) and not trees[k] then
if age>=7200 and h.canopy<1.5 and (p.x*7+p.z*11)%23==0 then
local recruit=diversity.choose_tree(p,h)
if recruit then return change(p,recruit) end
end
if k=="clover" and age>=900 then
local below=at(p,0,-1,0)
local soil=api.get_meta(below)
local fixed=number(soil,"succ_fixed_nitrogen",2)
-- At most two nitrogen additions per support, across deaths and
-- restart. No endlessly farmable item or per-tick fertilizer loop.
if fixed<2 and fertility<4 and permitted(p) and permitted(below) then
nutrients(p,1); soil:set_string("succ_fixed_nitrogen",tostring(fixed+1))
end
end
if age>=21600 then
if diversity.is_rock(node(at(p,0,-1,0))) then return change(p,nil) end
return change(p,"litter")
end
if age>=900 and serial%3==0 then disperse(p,k,serial) end
elseif k=="spring_greens_patch" or k=="autumn_mushrooms_patch" then
if age>=900 and serial%3==0 then disperse(p,k,serial) end
elseif k=="meadow_grass" or k=="wildflowers" or k=="fern" then
-- Occasional native woody recruitment in old meadows, not an instant forest.
local recruitment_age = h.grass>=12 and fertility==0 and 14400 or 7200
if k=="meadow_grass" and age>=recruitment_age and h.canopy<(diversity and 1.5 or 0.5) and h.moisture>=0.4
and (p.x*7+p.z*11)%23==0 then
local recruit=diversity and diversity.choose_tree(p,h) or (not diversity and "tree_seedling")
if recruit then return change(p,recruit) end
end
if age>=(k=="wildflowers" and 14400 or 21600) then return change(p,"litter") end
if age>=900 and serial%3==0 then disperse(p,k,serial) end
end
return false
end
local function nearby(p, fn)
-- Nine cells, with one mutation at most. API input is never an unbounded radius.
for x=-1,1 do for z=-1,1 do
local q = at(p,x,0,z)
if fn(q) then return true end
end end
return false
end
function S.forage(raw)
local p = position(raw)
if not p or not node(p) or not permitted(p) then return false end
return nearby(p,function(q)
local n = node(q)
local k=kind(n)
if not natural(q,n) then return false end
if botany and botany.kind(k) then
if not botany.browse(k,hearth.seasons and hearth.seasons.season() or "Summer")
or (trees[k] and family(k)~="tree_seedling") then return false end
elseif diversity then if not diversity.edible(k) then return false end
elseif k~="meadow_grass" and k~="fern" then return false end
if hearth.seasons and not hearth.seasons.available(k) then return false end
if not change(q,nil) then return false end
api.get_meta(q):set_string("succ_next",tostring(os.time()+300))
return true
end)
end
function S.deposit(raw)
local p = position(raw)
if not p or not node(p) or not permitted(p) then return false end
return nearby(p,function(q)
local n = node(q)
if not n or n.name~="air" then return false end
if number(api.get_meta(q),"succ_next")>os.time() then return false end
if not change(q,"dung") then return false end
api.get_meta(q):set_string("succ_next",tostring(os.time()+PERIOD))
return true
end)
end
function S.step(dtime)
if type(dtime)~="number" or dtime~=dtime or dtime<0 then return 0 end
elapsed=math.min(S.INTERVAL,elapsed+dtime)
if elapsed<S.INTERVAL then return 0 end
elapsed=0 -- Never replay a stalled frame's backlog.
local players = api.get_connected_players()
if #players==0 then seen,ring,ring_index={},{},1; return 0 end
local now, work = os.time(), 0
local side = S.RADIUS*2+1
for _=1,S.BUDGET do
player_index=player_index%#players+1
local p = position(players[player_index]:get_pos())
if p then
local index = math.floor(cursor/#players)%(side*side)
local x,z = index%side-S.RADIUS, math.floor(index/side)-S.RADIUS
for y=6,-5,-1 do
local q = at(p,x,y,z)
-- At most twelve visual swaps per probe (96/second globally).
-- Includes legacy mapgen leaves above the first natural ground.
if hearth.seasons then
local current = node(q)
if current then hearth.seasons.reconcile(q,current,now) end
end
if diversity then diversity.bootstrap(q,node(q)) end
local sub=botany and substrate(q)
if ground(at(q,0,-1,0)) or sub then
observe(q,now)
-- Visit an epiphyte and its support in the SAME bounded
-- column. Stopping above deadwood would freeze its clock.
if not sub or (sub.substrate~="bark" and sub.substrate~="deadwood" and sub.substrate~="litter") then break end
end
end
end
cursor=(cursor+1)%(side*side*#players)
work=work+1
end
return work
end
api.register_globalstep(S.step)
local reload_nodes = {"hearth:grass", "hearth:soil"}
if botany then reload_nodes[#reload_nodes+1]="hearth:water_source" end
if diversity then
for k in pairs(diversity.grounds) do reload_nodes[#reload_nodes+1]=PREFIX..k end
for k in pairs(diversity.rocks) do reload_nodes[#reload_nodes+1]=PREFIX..k end
if botany then
reload_nodes[#reload_nodes+1]=PREFIX.."log"
for _,species in ipairs(diversity.species_order) do reload_nodes[#reload_nodes+1]=PREFIX..species.."_log" end
end
end
for k in pairs(kinds) do reload_nodes[#reload_nodes+1]=PREFIX..k end
if hearth.seasons then
for _, name in ipairs(hearth.seasons.names()) do reload_nodes[#reload_nodes+1]=name end
end
api.register_lbm({name="hearth:succession_loaded", label="Pause natural succession across unloading",
nodenames=reload_nodes, run_at_every_load=true,
action=function(p)
seen[key(p)]=nil
if ground(p) then seen[key(at(p,0,1,0))]=nil end
if botany then
seen[key(at(p,0,1,0))]=nil
local component=api.get_meta(p):get_string("succ_component")
if component~="" then seen[component]=nil end
end
end})
return S
end