-- 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=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 "") 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 hightime 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=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