1052 lines
52 KiB
Lua
Executable File
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
|