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

1052 lines
52 KiB
Lua
Executable File

-- Calendar age with bounded loaded reconciliation. No emergence or event replay.
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 lifecycle = hearth.lifecycle
local DAY, YEAR = 86400, 365.2425*86400
local DECAY = {dung=600,litter=90*DAY,deadwood=YEAR,fungi=2400}
-- One synchronous, separately paid cache handoff, never a saved animal seed.
local cache_nut
local function resource_now()
local t=api.now and api.now() or os.time()
if type(t)~="number" or t~=t or t<0 or t>9007199254740991-YEAR then return nil end
return math.floor(t)
end
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 function valid_clock(n)
return type(n)=="number" and n==n and n>=0 and n<=9007199254740991
end
local function stored(m,f,default,maximum)
local s=m:get_string(f)
if s=="" then return default end
local n=tonumber(s)
if not valid_clock(n) or n>(maximum or 9007199254740991) then return nil end
return n
end
local function profile_for(k,p)
local id=botany and botany.kind(k) or (diversity and diversity.tree_info(k))
local profile=id and lifecycle and lifecycle.plant(id)
if not profile and (trees[k] or (k=="log" and api.get_meta(p):get_string("succ_worldgen_stage")=="mature")) and not id then
-- Unidentified legacy tree: explicit conservative family policy, not
-- a silently assigned birch identity or an accelerated timber crop.
profile={id="generic_tree",kind="plant",confidence="policy_fallback",
juvenile_days={min=365.2425,max=365.2425},maturity_days={min=3652.425,max=3652.425},
lifespan_days=false,germination_days=false}
end
if not profile then return nil end
local seed=math.abs(p.x*73856093.0+p.y*19349663.0+p.z*83492791.0)%2147483647
return profile,seed
end
-- Persist one root anchor, never reinterpret stage-local legacy active seconds.
-- Inferred establishment age is separate from the observed adoption epoch.
local function calendar(p,k,now,origin)
if not valid_clock(now) then return nil end
local profile,seed=profile_for(k,p)
if not profile then return nil end
local m=api.get_meta(p)
local version=m:get_string("succ_lifecycle_version")
if version=="" then
-- A partly erased record is corruption, not permission to reroll age.
if m:get_string("succ_birth_at")~="" or m:get_string("succ_lifecycle_profile")~="" then return nil end
local offset=0
origin=origin or m:get_string("succ_pending_origin")
if origin=="" or not origin then origin="legacy_established" end
local stage=diversity and select(2,diversity.tree_info(k))
stage=stage or ({tree_seedling="seedling",young_tree="young",mature_tree="mature"})[k]
stage=stage or (m:get_string("succ_worldgen_stage")=="mature" and "mature")
if origin~="natural_seed" then
local threshold
if stage=="seedling" then threshold=profile.germination_days
elseif stage=="young" then threshold=profile.juvenile_days
else threshold=profile.maturity_days end
offset=threshold and threshold.max*DAY or 0
end
m:set_string("succ_birth_at",tostring(now))
m:set_string("succ_initial_age_seconds",tostring(offset))
m:set_string("succ_lifecycle_seed",tostring(seed))
m:set_string("succ_lifecycle_origin",origin)
m:set_string("succ_lifecycle_profile",profile.id)
m:set_string("succ_lifecycle_policy","calendar_v1_conservative_max")
m:set_string("succ_last_observed_at",tostring(now))
m:set_string("succ_seed_epoch",tostring(now))
m:set_string("succ_lifecycle_version","1")
m:set_string("succ_pending_origin","")
end
if m:get_string("succ_lifecycle_version")~="1" or m:get_string("succ_lifecycle_profile")~=profile.id then return nil end
local born=tonumber(m:get_string("succ_birth_at"))
local initial=tonumber(m:get_string("succ_initial_age_seconds"))
local previous=tonumber(m:get_string("succ_last_observed_at"))
local stored_seed=tonumber(m:get_string("succ_lifecycle_seed"))
local epoch=tonumber(m:get_string("succ_seed_epoch"))
if not valid_clock(born) or not valid_clock(initial) or not valid_clock(previous) or not valid_clock(epoch)
or not stored_seed or stored_seed<0 or stored_seed>2147483646 or stored_seed%1~=0
or previous<born or now<previous or now<born then return nil end
local age=initial+(now-born)
if not valid_clock(age) then return nil end
local phase
if lifecycle then phase=lifecycle.phase(profile,0,age)
else
phase={phase=age<profile.juvenile_days.max*DAY and "seedling"
or (age<profile.maturity_days.max*DAY and "juvenile" or "mature"),age_seconds=age,death_due=false}
end
if not phase then return nil end
if previous~=now then m:set_string("succ_last_observed_at",tostring(now)) end
return phase,profile
end
local function flowering_ready(q,r,now)
local m=api.get_meta(q)
if m:get_string("succ_lifecycle_version")=="" then return true end -- established, not yet adopted
if m:get_string("succ_lifecycle_version")~="1" or not lifecycle then return false end
local born=tonumber(m:get_string("succ_birth_at"))
local initial=tonumber(m:get_string("succ_initial_age_seconds"))
local high=tonumber(m:get_string("succ_last_observed_at"))
if not valid_clock(born) or not valid_clock(initial) or not valid_clock(high) or now<born or now<high then return false end
local profile=lifecycle.plant(r.id)
local phase=profile and lifecycle.phase(profile,0,initial+now-born)
return phase~=nil and phase.phase=="mature"
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 habitat_now=os.time()
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
local blooms=botany.pollinator(k,season) and flowering_ready(q,r,habitat_now)
if blooms 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 blooms 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 "")
local oldprofile=m:get_string("succ_lifecycle_profile")
local nextprofile=target and profile_for(target,p)
local same=nextprofile and oldprofile==nextprofile.id
if not same then
for _,field in ipairs({"succ_birth_at","succ_initial_age_seconds","succ_last_observed_at","succ_lifecycle_version",
"succ_lifecycle_seed","succ_lifecycle_origin","succ_lifecycle_profile","succ_lifecycle_policy","succ_seed_epoch","succ_litter_epoch"}) do
m:set_string(field,"")
end
m:set_string("succ_age","0")
if nextprofile then calendar(p,target,os.time(),"natural_seed") end
end
m:set_string("succ_stress", "0")
if target=="litter" or target=="deadwood" then
m:set_string("succ_decay_at",tostring(os.time()))
m:set_string("succ_decay_seen_at",tostring(os.time()))
m:set_string("succ_decay_initial", "0")
m:set_string("succ_decay_bonus", "0")
m:set_string("succ_litter_remaining",target=="litter" and "1" or "")
end
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)
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)
if m:get_string("succ_worldgen_stage")=="mature" then
f="mature_tree"
local id=m:get_string("succ_species")
if id~="generic" then species=id end
end
local record=botany and botany.get(k)
local calendar_profile=profile_for(k,p)
local phase=calendar_profile and calendar(p,k,now)
if calendar_profile and not phase then return false end
-- Dead organic matter also has a calendar, independent of visit count.
-- Legacy active seconds are retained as a small observed initial age.
local organic=k=="litter" or k=="deadwood"
local decay_age
if organic then
local born=stored(m,"succ_decay_at",now)
local high=stored(m,"succ_decay_seen_at",now)
local initial=stored(m,"succ_decay_initial",number(m,"succ_age",DECAY[k]),DECAY[k])
local bonus=stored(m,"succ_decay_bonus",0,DECAY[k])
if not born or not high or not initial or not bonus or born>now or high>now then return false end
decay_age=initial+now-born+bonus
m:set_string("succ_decay_at",tostring(born)); m:set_string("succ_decay_seen_at",tostring(now))
m:set_string("succ_decay_initial",tostring(initial))
end
if dt==0 then return false end -- one cheap baseline, no side effects replay
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 = decay_age or (phase and phase.age_seconds) or number(m,"succ_age",1000000)+age_dt
-- Keep the old stage-local counter untouched on migrated living roots.
if not phase then m:set_string("succ_age", tostring(age)) end
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 and not phase 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 = DECAY[k]
if age>=lifetime then
local remaining=k=="litter" and stored(m,"succ_litter_remaining",1,1) or 1
if not remaining then return false end
local target = k=="deadwood" and "litter" or nil
if change(p,target) then
if k=="litter" then m:set_string("succ_litter_remaining","0") end
nutrients(p,k=="dung" and 4 or (k=="deadwood" and 3 or remaining)); 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>=90*DAY 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=calendar_profile and calendar_profile.juvenile_days and calendar_profile.juvenile_days.max*DAY or YEAR
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
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
local maturity=calendar_profile and calendar_profile.maturity_days and calendar_profile.maturity_days.max*DAY or 10*YEAR
if f=="young_tree" and age>=maturity and h.canopy<2 then return change(p,species and species.."_mature" or "mature_tree") end
if f=="mature_tree" then
-- One present seed opportunity, never one offspring per missed year.
if phase and phase.phase=="mature" and (not record or botany.seeding(k,season))
and now>=number(m,"succ_seed_epoch",9007199254740991) then
m:set_string("succ_seed_epoch",tostring(now+YEAR))
disperse(p,species and species.."_seedling" or "tree_seedling",serial)
end
if now>=number(m,"succ_litter_epoch",9007199254740991) then
m:set_string("succ_litter_epoch",tostring(now+30*DAY)); disperse(p,"litter",serial)
end
elseif record and not trees[k] then
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=DECAY[h.substrate]
local bonus=stored(wood,"succ_decay_bonus",0,limit)
if bonus then wood:set_string("succ_decay_bonus",tostring(math.min(limit,bonus+math.min(dt,PERIOD)))) end
end
end
-- Unknown maturity (fungi/lichens) is not automatic propagation or
-- death permission. A sprite persists as its root/colony observation.
if phase and phase.phase=="mature" and botany.seeding(k,season)
and now>=number(m,"succ_seed_epoch",9007199254740991) then
m:set_string("succ_seed_epoch",tostring(now+30*DAY)); 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
-- All animal resource probes are read-only until their complete preflight.
-- No bootstrap/adoption, calendar writes, callbacks or inventory grants here.
local function resource_site(q)
local n=node(q)
local below=at(q,0,-1,0)
if not n or not node(below) or not natural(q,n) or not permitted(q) or not permitted(below) then return nil end
local component=api.get_meta(q):get_string("succ_component")
if component~="" and component~=key(q) then return nil end
if not ground(below) or (diversity and not diversity.is_ground(node(below))) then return nil end
return n,api.get_meta(q),below
end
local function mature_resource(q,r,time)
local m=api.get_meta(q)
if not lifecycle or not r then return false end
local profile=lifecycle.plant(r.id)
if not profile or not profile.maturity_days then return false end
if m:get_string("succ_lifecycle_version")=="" then
-- Same established-stand assumption as calendar adoption, without
-- mutating a failed transaction or treating a seedling as mature.
if m:get_string("succ_birth_at")~="" or m:get_string("succ_lifecycle_profile")~="" then return false end
local k=kind(node(q))
return r.group~="trees" or family(k)=="mature_tree"
or (family(k)=="log" and m:get_string("succ_worldgen_stage")=="mature")
end
if m:get_string("succ_lifecycle_version")~="1" or m:get_string("succ_lifecycle_profile")~=r.id then return false end
local born=stored(m,"succ_birth_at",nil)
local initial=stored(m,"succ_initial_age_seconds",nil)
local high=stored(m,"succ_last_observed_at",nil)
if not born or not initial or not high or high<born or time<high or time<born then return false end
local phase=lifecycle.phase(profile,0,initial+time-born)
return phase and phase.phase=="mature" or false
end
local function resource_season(time)
local c=hearth.climate_at and hearth.climate_at(time)
return c and c.season or (hearth.seasons and hearth.seasons.season(time))
end
function S.pollinate(raw)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time or not botany then return false end
local n,m=resource_site(p)
local r=n and botany.get(n)
local season=resource_season(time)
if not r or not botany.active(n,season) or not botany.pollinator(n,season) or not mature_resource(p,r,time) then return false end
local ready=stored(m,"succ_nectar_ready_at",0)
local pollen=stored(m,"succ_pollinated_until",0)
if not ready or not pollen or ready>time or pollen>time+DAY then return false end
m:set_string("succ_nectar_ready_at",tostring(time+3600))
-- Boolean shared credit: not an accumulating yield counter or seeds.
if pollen<=time then m:set_string("succ_pollinated_until",tostring(time+DAY)) end
return true
end
function S.frugivore(raw,species_id)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time or not botany then return false end
local n,m=resource_site(p)
local r=n and botany.get(n)
local season=resource_season(time)
if not r or (species_id~=nil and species_id~=r.id) or r.group~="shrubs"
or not botany.active(n,season) or not botany.edible(n,season) or not mature_resource(p,r,time) then return false end
local ready=stored(m,"botany_ready_at",0)
if not ready or ready>time then return false end
-- Eat one unit, discard the rest of the existing two-fruit batch.
-- Share the human lock, never create a second animal-only reserve.
m:set_string("botany_ready_at",tostring(time+YEAR))
return true
end
function S.granivore(raw,species_id)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time or not botany then return false end
local n,m=resource_site(p)
local r=n and botany.get(n)
local season=resource_season(time)
if not r or (species_id~=nil and species_id~=r.id) or (r.id~="oak" and r.id~="hazel")
or season~="Autumn" or not botany.seeding(n,season) or not mature_resource(p,r,time) then return false end
local rootkind=family(kind(n))
if rootkind~="mature_tree" and not (rootkind=="log" and m:get_string("succ_worldgen_stage")=="mature") then return false end
local ok,date=pcall(os.date,"!*t",time)
local year=ok and type(date)=="table" and date.year or nil
if not valid_clock(year) then return false end
local previous=stored(m,"succ_nuts_year",0)
local remaining=stored(m,"succ_nuts_remaining",2,2)
if not previous or not remaining or previous%1~=0 or remaining%1~=0 or previous>year then return false end
if previous<year then remaining=2 end -- current crop only, no missed-year replay
if remaining<1 then return false end
local cache=remaining>=2
m:set_string("succ_nuts_year",tostring(year))
m:set_string("succ_nuts_remaining",tostring(remaining-(cache and 2 or 1)))
if cache then cache_nut={id=r.id,time=time,source=p}; return true,r.id end
return true
end
function S.disperse_seed(raw,species_id)
local paid=cache_nut; cache_nut=nil -- every attempt spends it, even failure
local p,time=position(raw),resource_now()
if not paid or not p or not time or paid.time~=time or paid.id~=species_id then return false end
-- Immediate local handoff only. Recheck the donor without replenishing it.
if math.abs(p.x-paid.source.x)>2 or p.y~=paid.source.y or math.abs(p.z-paid.source.z)>2 then return false end
local donor=resource_site(paid.source)
if not donor or botany.kind(donor)~=species_id then return false end
local n=node(p); local below=at(p,0,-1,0)
if not n or n.name~="air" or not node(below) or not ground(below)
or not permitted(p) or not permitted(below) then return false end
local m=api.get_meta(p)
local ready=stored(m,"succ_next",0)
if not ready or ready>time then return false end
if not change(p,species_id.."_seedling") then return false end
m:set_string("succ_next",tostring(time+DAY))
return true
end
function S.detritivore(raw)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time then return false end
local n,m,below=resource_site(p)
if not n or kind(n)~="litter" then return false end
local remaining=stored(m,"succ_litter_remaining",1,1)
local fertility=stored(api.get_meta(below),"succ_nutrients",0,8)
if not remaining or remaining<0.099999 or not fertility or fertility>7.9 then return false end
local last=remaining<0.100001
-- Final removal must preflight any dependent fungus BEFORE any debit.
if last and not change(p,nil) then return false end
m:set_string("succ_litter_remaining",tostring(last and 0 or remaining-0.1))
api.get_meta(below):set_string("succ_nutrients",tostring(fertility+0.1))
return true
end
function S.forage(raw,mode)
cache_nut=nil
local p = position(raw)
local time=resource_now()
if not p or not time or not node(p) or not permitted(p) then return false end
return nearby(p,function(q)
local n,m = resource_site(q)
local k=kind(n)
if not n then return false end
if botany and botany.kind(k) then
local season=hearth.seasons and hearth.seasons.season() or "Summer"
local ordinary=botany.browse(k,season)
local seasonal=false
if (mode=="seasonal_browse" or mode=="winter_browse")
and (season=="Autumn" or season=="Winter") then
local profile=botany.get(k)
seasonal=profile and profile.tags.browse and not profile.toxic
and (profile.group=="trees" or profile.group=="shrubs" or profile.group=="grasses")
end
local stage=family(k)
local tree_identity=diversity and diversity.tree_info(k)
local living_tree=tree_identity~=nil or trees[k]
if (not ordinary and not seasonal) or (living_tree and stage~="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 mode~="seasonal_browse" and mode~="winter_browse"
and hearth.seasons and not hearth.seasons.available(k) then return false end
local ready=stored(m,"succ_browse_ready_at",0)
if not ready or ready>time then return false end
-- One partial canopy ration, not destruction of the whole perennial.
-- Seven real days is a conservative game fallback, not measured growth.
m:set_string("succ_browse_ready_at",tostring(time+7*DAY))
return true
end)
end
function S.deposit(raw)
cache_nut=nil
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="Reconcile calendar roots within the loaded work budget",
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