This commit is contained in:
2026-11-05 09:57:11 -05:00
parent d34388bf2c
commit 89e392cc2e
81 changed files with 12410 additions and 1135 deletions
+336 -43
View File
@@ -1,4 +1,4 @@
-- Original loaded-only succession. No ABMs, map emergence, timers or world registry.
-- 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}
@@ -9,6 +9,16 @@ return function(hearth, api)
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.
@@ -56,6 +66,98 @@ return function(hearth, api)
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)
@@ -165,6 +267,7 @@ return function(hearth, api)
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
@@ -203,10 +306,11 @@ return function(hearth, api)
end
if not h.host and r.group=="trees" then h.host=r.id end
if r.group=="grasses" then h.grass=h.grass+1 end
if botany.pollinator(k,season) and r.group~="trees" then h.flowers=h.flowers+1 end
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 botany.pollinator(k,season) and not component then h.pollination=h.pollination+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
@@ -425,7 +529,25 @@ return function(hearth, api)
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")
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
@@ -480,21 +602,44 @@ return function(hearth, api)
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)
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 = number(m,"succ_age",1000000)+age_dt
m:set_string("succ_age", tostring(age))
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))
@@ -546,24 +691,29 @@ return function(hearth, api)
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
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 = ({dung=600,litter=1800,deadwood=7200,fungi=2400})[k]
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 nutrients(p,k=="dung" and 4 or (k=="deadwood" and 3 or 1)); return true end
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>=900 and record.tags.nitrogenfixer then
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)
@@ -572,11 +722,10 @@ return function(hearth, api)
soil:set_string("succ_fixed_nitrogen",tostring(fixed+delta))
end
end
local seed_age=21600
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
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
@@ -585,13 +734,19 @@ return function(hearth, api)
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
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
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
-- 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
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;
@@ -599,24 +754,17 @@ return function(hearth, api)
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))))
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
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)
-- 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
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)
@@ -660,25 +808,170 @@ return function(hearth, api)
end end
return false
end
function S.forage(raw)
-- 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)
if not p or not node(p) or not permitted(p) then return false end
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 = node(q)
local n,m = resource_site(q)
local k=kind(n)
if not natural(q,n) then return false end
if not 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
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 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))
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)
@@ -743,7 +1036,7 @@ return function(hearth, api)
if hearth.seasons then
for _, name in ipairs(hearth.seasons.names()) do reload_nodes[#reload_nodes+1]=name end
end
api.register_lbm({name="hearth:succession_loaded", label="Pause natural succession across unloading",
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