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