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
+20 -1
View File
@@ -11,7 +11,7 @@ return function(hearth, api, register_node, construction)
for i, name in ipairs(list) do out[i] = node(name) end
return out
end
M.species_order = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"}
M.species_order = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce", "crab_apple", "wild_cherry", "field_maple"}
M.rocks = {"granite", "basalt", "limestone", "slate", "sandstone"}
-- Moisture/fertility/drainage are normalized habitat preferences, NOT player
-- climate values. The succession owner decides how to consume these hints.
@@ -69,6 +69,15 @@ return function(hearth, api, register_node, construction)
spruce = {height=10, width=5, crown="spire", heat={0,40}, humidity={40,85},
y_min=8, y_max=185, soils={"podzol", "forest_floor", "alpine_turf"},
biomes={"hearth_pine_taiga", "hearth_birch_forest"}, shade=0.85, litter=0.5},
crab_apple = {height=6,width=7,crown="round",heat={35,75},humidity={30,75},
y_min=3,y_max=100,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.65,litter=0.7},
wild_cherry = {height=9,width=7,crown="horizontal",heat={35,75},humidity={30,70},
y_min=4,y_max=120,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.6,litter=0.8},
field_maple = {height=7,width=9,crown="lobed_broad",heat={35,80},humidity={25,75},
y_min=3,y_max=110,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.8,litter=0.8},
}
function M.supports_species(species, surface)
local p = M.profiles[species]
@@ -122,6 +131,16 @@ return function(hearth, api, register_node, construction)
elseif species == "spruce" then
local radius = y >= 1 and math.min(2,math.floor((h-1-y)/3)) or -1
leaf = r <= radius and dx+dz <= radius+1
elseif species == "crab_apple" then
leaf = y>=2 and dx*dx+dz*dz <= (y==h-1 and 2 or (y==2 and 5 or 10))
trunk = trunk or (y==2 and dx+dz<=2 and (dx==0 or dz==0))
elseif species == "wild_cherry" then
leaf = y>=4 and r<=(y==h-1 and 1 or 3) and dx+dz<=4 and (y%2==0 or r<=1)
trunk = trunk or ((y==4 or y==6) and dz==0 and dx<=2)
elseif species == "field_maple" then
leaf = y>=2 and r<=(y==h-1 and 2 or 4) and dx+dz<=(y==h-1 and 3 or 5)
and not (dx==dz and r>=2)
trunk = trunk or (y==3 and dx+dz<=3 and (dx==0 or dz==0))
end
local stem = canonical and "log" or species .. "_log"
local leaves = canonical and "leaves" or species .. "_leaves"
+72 -12
View File
@@ -54,6 +54,9 @@ return function(hearth, api)
add("rowan","Rowan","Sorbus aucuparia","acid",grow,"pollinator,browse,nursery")
add("hazel","Common hazel","Corylus avellana","wood",grow,"browse,ectomycorrhiza,nursery")
add("spruce","Norway spruce","Picea abies","acid",year,"ectomycorrhiza,nursery,resin")
add("crab_apple","Crab apple","Malus sylvestris","wood",grow,"pollinator,browse,nursery")
add("wild_cherry","Wild cherry","Prunus avium","wood",grow,"pollinator,browse,nursery")
add("field_maple","Field maple","Acer campestre","wood",grow,"pollinator,browse,nursery")
group="shrubs"
add("bilberry","Bilberry","Vaccinium myrtillus","acid",grow,"pollinator,browse,nursery",{food=true})
add("blackberry","Pleated blackberry","Rubus plicatus","shrub",grow,"pollinator,browse,nursery",{food=true})
@@ -65,6 +68,10 @@ return function(hearth, api)
add("holly","European holly","Ilex aquifolium","wood",year,"pollinator,browse,nursery",{toxic=true})
add("juniper","Common juniper","Juniperus communis","acid",year,"browse,nursery,resin")
add("guelder_rose","Guelder rose","Viburnum opulus","wetwood",grow,"pollinator,browse,nursery",{toxic=true})
add("honeysuckle","Honeysuckle","Lonicera periclymenum","shrub",grow,"pollinator,nursery",{toxic=true})
add("ivy","Ivy","Hedera helix","shade",year,"pollinator,nursery",{toxic=true})
add("gorse","Gorse","Ulex europaeus","acid",year,"nitrogenfixer,pollinator,nursery",{toxic=true})
add("broom","Broom","Cytisus scoparius","acid",grow,"nitrogenfixer,pollinator,nursery",{toxic=true})
group="grasses"
add("sedge","Greater tussock-sedge","Carex paniculata","bank",grow,"browse,engineer,nursery",{existing=true})
add("bulrush","Bulrush (great reedmace)","Typha latifolia","bank",grow,"engineer,nursery",{existing=true})
@@ -87,6 +94,18 @@ return function(hearth, api)
add("wild_garlic","Wild garlic","Allium ursinum","shade","Spring","pollinator")
add("cowslip","Cowslip","Primula veris","meadow","Spring","pollinator")
add("wood_anemone","Wood anemone","Anemone nemorosa","shade","Spring","pollinator",{toxic=true})
add("dandelion","Dandelion","Taraxacum officinale","meadow",grow,"pollinator,browse")
add("daisy","Daisy","Bellis perennis","meadow",grow,"pollinator")
add("red_clover","Red clover","Trifolium pratense","meadow",grow,"nitrogenfixer,pollinator,browse")
add("birdsfoot_trefoil","Bird's-foot trefoil","Lotus corniculatus","meadow",grow,"nitrogenfixer,pollinator,browse",{toxic=true})
add("ribwort_plantain","Ribwort plantain","Plantago lanceolata","meadow",grow,"browse")
add("selfheal","Selfheal","Prunella vulgaris","meadow",grow,"pollinator")
add("knapweed","Common knapweed","Centaurea nigra","meadow",grow,"pollinator,nursery")
add("oxeye_daisy","Oxeye daisy","Leucanthemum vulgare","meadow",grow,"pollinator")
add("field_scabious","Field scabious","Knautia arvensis","meadow",grow,"pollinator")
add("teasel","Wild teasel","Dipsacus fullonum","meadow",grow,"pollinator,nursery")
add("cow_parsley","Cow parsley","Anthriscus sylvestris","shrub",grow,"pollinator,nursery")
add("garlic_mustard","Garlic mustard","Alliaria petiolata","shade",grow,"pollinator,nursery")
group="ferns_mosses"
add("bracken","Bracken","Pteridium aquilinum","acid",grow,"nursery,allelopathy",{toxic=true})
add("lady_fern","Lady-fern","Athyrium filix-femina","shade",grow,"nursery")
@@ -98,6 +117,7 @@ return function(hearth, api)
add("moss","Cypress-leaved plait-moss","Hypnum cupressiforme","moss",year,"engineer,nursery",{existing=true})
add("fork_moss","Broom fork-moss","Dicranum scoparium","moss",year,"nursery")
add("star_moss","Swan's-neck thyme-moss","Mnium hornum","moss",year,"nursery")
add("holly_fern","Soft shield-fern (contract ID holly fern)","Polystichum setiferum","shade",year,"nursery")
group="fungi"
add("fly_agaric","Fly agaric","Amanita muscaria","litter","Summer,Autumn","ectomycorrhiza",{toxic=true})
add("birch_bolete","Brown birch bolete","Leccinum scabrum","litter","Summer,Autumn","ectomycorrhiza")
@@ -137,6 +157,16 @@ return function(hearth, api)
niche("rowan",{light={5,15},moisture={.3,.85}})
niche("spruce",{moisture={.4,.85},light={4,15}})
niche("hazel",{ph={5,8}})
niche("crab_apple",{light={7,15},moisture={.3,.75},ph={5,8}})
niche("wild_cherry",{light={7,15},moisture={.3,.7},ph={5.5,8}})
niche("field_maple",{light={5,15},moisture={.25,.75},ph={5.5,8}})
niche("gorse",{light={10,15},moisture={.2,.65},ph={3.5,6.5}})
niche("broom",{light={9,15},moisture={.2,.65},ph={4,7}})
niche("ivy",{light={3,15},moisture={.3,.85},ph={5,8}})
niche("birdsfoot_trefoil",{moisture={.2,.7},ph={5,8}})
niche("ribwort_plantain",{moisture={.2,.8}})
niche("field_scabious",{moisture={.2,.65},ph={6,8}})
niche("holly_fern",{light={3,10},moisture={.5,.9},ph={5,8}})
niche("juniper",{ph={4,8},moisture={.15,.6}})
niche("nettle",{light={5,15},nutrients=1})
niche("cowslip",{ph={6,8},moisture={.2,.65}})
@@ -171,8 +201,21 @@ return function(hearth, api)
bluebell="Spring",yarrow="Summer,Autumn",foxglove="Summer",meadowsweet="Summer",
wild_garlic="Spring",cowslip="Spring",wood_anemone="Spring",yellow_waterlily="Summer",
white_waterlily="Summer",water_crowfoot="Spring,Summer",water_mint="Summer,Autumn",
water_forgetmenot="Spring,Summer",brooklime="Spring,Summer"}
water_forgetmenot="Spring,Summer",brooklime="Spring,Summer",
crab_apple="Spring",wild_cherry="Spring",field_maple="Spring",
dandelion="Spring,Summer,Autumn",daisy="Spring,Summer,Autumn",red_clover="Summer,Autumn",
birdsfoot_trefoil="Summer",selfheal="Summer,Autumn",knapweed="Summer,Autumn",
oxeye_daisy="Summer",field_scabious="Summer,Autumn",teasel="Summer",
cow_parsley="Spring,Summer",garlic_mustard="Spring",honeysuckle="Summer,Autumn",
ivy="Autumn",gorse="Spring,Summer,Autumn,Winter",broom="Spring,Summer"}
for id,text in pairs(flowers) do index[id].flowering=set(text) end
-- Seed-season windows are qualitative phenology, not permission to replay
-- every missed crop of seeds. Individual maturity is checked by succession.
for _,r in ipairs(records) do
r.seeding=set(r.group=="trees" and "Autumn" or "Summer,Autumn")
if r.id=="ivy" then r.seeding=set("Spring") end
if r.id=="garlic_mustard" or r.id=="cow_parsley" then r.seeding=set("Summer") end
end
for _,id in ipairs({"bilberry","blackberry","raspberry"}) do
index[id].fruiting=set(id=="blackberry" and "Summer,Autumn" or "Summer")
end
@@ -199,7 +242,7 @@ return function(hearth, api)
function B.kind(value)
local k=raw_kind(value)
if index[k] then return k end
local id,part=(k or ""):match("^([a-z]+)_([a-z]+)$")
local id,part=(k or ""):match("^([a-z_]+)_([a-z]+)$")
if index[id] and index[id].group=="trees" and
({seedling=true,young=true,mature=true,leaves=true,log=true,planks=true})[part] then return id end
end
@@ -235,8 +278,9 @@ return function(hearth, api)
end
end
function B.pollinator(value,season)
local r=record(value); return r~=nil and r.tags.pollinator==true and r.flowering[season]==true
local r=record(value); return r~=nil and r.tags.pollinator==true and r.flowering~=nil and r.flowering[season]==true
end
function B.seeding(value,season) local r=record(value); return r~=nil and r.seeding[season]==true end
function B.edible(value,season)
local r=record(value); return r~=nil and r.food==true and not r.toxic and r.fruiting[season]==true
end
@@ -285,10 +329,11 @@ return function(hearth, api)
end
end
local DAY=86400
local YEAR=365.2425*DAY -- shared with succession.frugivore; resource policy, not lifespan
local function now()
local n
if api.now then n=api.now() else n=os.time() end
if not finite(n) or n<0 or n>9007199254740991-DAY then return nil end
if not finite(n) or n<0 or n>9007199254740991-YEAR then return nil end
return math.floor(n)
end
function B.harvest(pos,player)
@@ -309,17 +354,32 @@ return function(hearth, api)
local stored=meta:get_string("botany_ready_at")
local ready=0
if stored~="" then ready=tonumber(stored) end
if not finite(ready) or ready<0 then return false,"metadata" end
if not finite(ready) or ready<0 or ready>9007199254740991 then return false,"metadata" end
if ready>time then return false,"cooldown" end
local pollen_stored=meta:get_string("succ_pollinated_until")
local pollen=pollen_stored=="" and 0 or tonumber(pollen_stored)
-- Match succession.pollinate's exact expiry and one-day future bound.
if not finite(pollen) or pollen<0 or pollen>time+DAY then return false,"metadata" end
local amount=2+(pollen>time and 1 or 0)
local inv=player:get_inventory()
if not inv or not inv:room_for_item("main","hearth:berries 2") then return false,"inventory" end
-- Lock before inventory callbacks: reentrant harvest cannot duplicate fruit.
meta:set_string("botany_ready_at",tostring(time+DAY))
local leftover=inv:add_item("main","hearth:berries 2")
if not inv then return false,"inventory" end
-- Reserve both budgets before even room_for_item: callbacks may reenter.
-- Pollination modifies an existing standing crop; it never creates fruit
-- on an empty/cooling bush and is a boolean bonus, not doubled inventory.
meta:set_string("botany_ready_at",tostring(time+YEAR))
meta:set_string("succ_pollinated_until","0")
local function rollback()
meta:set_string("botany_ready_at",stored)
meta:set_string("succ_pollinated_until",pollen_stored)
end
local item="hearth:berries "..amount
if not inv:room_for_item("main",item) then rollback(); return false,"inventory" end
local leftover=inv:add_item("main",item)
local count=leftover and leftover:get_count()
if not finite(count) or count<0 or count>2 then return false,"inventory" end
if count==2 then meta:set_string("botany_ready_at",stored); return false,"inventory" end
return true,2-count
-- An invalid delivery result cannot safely be retried: keep the lock.
if not finite(count) or count%1~=0 or count<0 or count>amount then return false,"inventory" end
if count==amount then rollback(); return false,"inventory" end
return true,amount-count
end
local registered=false
function B.register_nodes(register_node)
+17 -12
View File
@@ -3,19 +3,19 @@ return function(hearth, api)
local C = {VERSION = 1, MAX_JSON = 2048, FORM = "hearth:character"}
local key = "hearth:appearance"
local options = {
body = {"compact", "balanced", "broad"},
body = {"compact", "lean", "balanced", "broad", "sturdy"},
skin = {"porcelain", "sand", "honey", "olive", "umber", "ebony"},
face = {"calm", "angular", "soft", "weathered"},
face = {"calm", "angular", "soft", "weathered", "lined"},
eyes = {"hazel", "brown", "green", "blue", "grey"},
hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none"},
hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none", "curly", "topknot", "undercut"},
hairColor = {"ash", "flax", "chestnut", "auburn", "black", "silver"},
beard = {"none", "stubble", "moustache", "short_beard", "full_beard"},
garment = {"tunic", "workshirt", "jerkin", "coat"},
beard = {"none", "stubble", "moustache", "short_beard", "full_beard", "goatee", "braided_beard"},
garment = {"tunic", "workshirt", "jerkin", "coat", "hooded_cloak", "travel_coat", "wool_mantle"},
cloth = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
trousers = {"earth", "slate", "moss", "charcoal"}, boots = {"walnut", "black", "tan"},
accent = {"none", "scarf", "apron", "sash", "vest"},
accent = {"none", "scarf", "apron", "sash", "vest", "satchel", "tool_belt", "pauldrons", "hood"},
accentColor = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
detail = {"none", "freckles", "scar", "freckles_scar"},
detail = {"none", "freckles", "scar", "freckles_scar", "birthmark"},
}
local order = {"body", "skin", "face", "eyes", "hair", "hairColor", "beard", "garment",
"cloth", "trousers", "boots", "accent", "accentColor", "detail"}
@@ -83,8 +83,8 @@ return function(hearth, api)
local function name(stem) return "hearth_char_" .. stem .. ".png" end
local texture = table.concat({name("skin_" .. p.skin), name("face_" .. p.face), name("eyes_" .. p.eyes),
name("garment_" .. p.garment .. "_" .. p.cloth), name("trousers_" .. p.trousers), name("boots_" .. p.boots),
name("accent_" .. p.accent .. "_" .. p.accentColor), name("hair_" .. p.hair .. "_" .. p.hairColor),
name("beard_" .. p.beard .. "_" .. p.hairColor), name("detail_" .. p.detail)}, "^")
name("hair_" .. p.hair .. "_" .. p.hairColor), name("beard_" .. p.beard .. "_" .. p.hairColor),
name("accent_" .. p.accent .. "_" .. p.accentColor), name("detail_" .. p.detail)}, "^")
return "hearth_person" .. (p.body == "balanced" and "" or "_" .. p.body) .. ".b3d", texture
end
local function publish(player, value)
@@ -120,7 +120,12 @@ return function(hearth, api)
hair = "Hair style", hairColor = "Hair & facial hair color", beard = "Facial hair", garment = "Garment",
cloth = "Cloth color", trousers = "Trousers", boots = "Boot leather", accent = "Accent",
accentColor = "Accent color", detail = "Face details"}
local names = {sidepart = "Side part", workshirt = "Work shirt", freckles_scar = "Freckles and scar"}
local names = {sidepart = "Side part", workshirt = "Work shirt", freckles_scar = "Freckles and scar",
hooded_cloak = "Hooded cloak", travel_coat = "Travel coat", wool_mantle = "Wool mantle",
tool_belt = "Tool belt", braided_beard = "Braided beard", body="Body shape",
compact="Compact", lean="Lean", balanced="Balanced", broad="Broad", sturdy="Sturdy",
curly="Curly", topknot="Topknot", undercut="Undercut", goatee="Goatee",
birthmark="Birthmark", pauldrons="Shoulder guards"}
local function title(id)
return names[id] or (id:gsub("_", " "):gsub("^%l", string.upper))
end
@@ -146,7 +151,7 @@ return function(hearth, api)
"model[0.65,1.65;5.1,6.4;traveler;" .. esc(mesh) .. ";" .. esc(texture)
.. ";0," .. st.yaw .. ";true;true;0,19;12]",
"button[0.7,8.25;1.55,0.6;front;Front]button[2.4,8.25;1.55,0.6;profile;Profile]button[4.1,8.25;1.55,0.6;back;Back]",
"tooltip[traveler;Drag to turn the preview. Only this model rotates.]"}
"tooltip[traveler;" .. esc("Drag to turn the preview. Only this model rotates.") .. "]"}
for i, tab in ipairs(tabs) do
f[#f+1] = "button[" .. (6.2 + (i-1)*1.8) .. ",1.65;1.7,0.65;tab_" .. i .. ";" .. tab[1] .. "]"
end
@@ -184,7 +189,7 @@ return function(hearth, api)
for k, v in pairs(fields) do
count = count + 1; if count > 20 then return false end
if visible[k] then
local n = type(v) == "string" and #v <= 1 and v:match("^[1-9]$") and tonumber(v)
local n = type(v) == "string" and #v <= 2 and v:match("^[1-9][0-9]?$") and tonumber(v)
if not n or not options[visible[k]][n] then return false end
draft[visible[k]] = options[visible[k]][n]
elseif not controls[k] or (type(v) ~= "string" and type(v) ~= "boolean")
+7
View File
@@ -25,6 +25,8 @@ local api = setmetatable({
end,
}, {__index = core})
local modpath = api.get_modpath("hearth")
-- Plain policy namespace, available before any factory captures calendar APIs.
hearth.lifecycle = dofile(modpath .. "/lifecycle.lua")
local construction = dofile(modpath .. "/construction.lua")(hearth, api)
local seasons = dofile(modpath .. "/seasons.lua")(hearth, api)
hearth.seasons = seasons
@@ -796,4 +798,9 @@ botany.register_nodes(register_node)
-- Species leaves and canonical ecological cubes/sprites must precede variants.
seasons.register()
-- Creatura's MIT-licensed boid and ambient-spawn APIs are optional at runtime;
-- the managed package pins and bundles the ContentDB 0.2.10 release.
if type(creatura) == "table" then hearth.creatura = creatura end
hearth.wildlife = dofile(modpath .. "/wildlife.lua")(hearth, api)
-- Wildlife owns factory loading; publish that exact catalogue, not a second one.
hearth.fauna = hearth.wildlife.fauna
+33 -11
View File
@@ -1,6 +1,6 @@
-- Species/niche policy only. Succession owns the one fixed-eight scheduler.
return function(hearth, api)
local D = {species_order={"oak","birch","pine","willow","alder","beech","aspen","rowan","hazel","spruce"},
local D = {species_order={"oak","birch","pine","willow","alder","beech","aspen","rowan","hazel","spruce","crab_apple","wild_cherry","field_maple"},
plants={"clover","nettle","heather","bluebell","sedge","bulrush","moss","lichen"},
grounds={grass=true,soil=true,forest_floor=true,podzol=true,alluvium=true,peat=true,alpine_turf=true},
rocks={stone=true,granite=true,basalt=true,limestone=true,slate=true,sandstone=true,scree=true}}
@@ -14,19 +14,23 @@ return function(hearth, api)
for _, plant in ipairs(D.plants) do flora[plant]=true end
function D.tree_info(value)
local k=kind(value)
local s,stage=(k or ""):match("^([a-z]+)_([a-z]+)$")
local s,stage=(k or ""):match("^([a-z_]+)_([a-z]+)$")
if species[s] and ({seedling=true,young=true,mature=true})[stage] then return s,stage end
end
function D.family(value)
local k=kind(value)
local s,stage=D.tree_info(k)
if s then return ({seedling="tree_seedling",young="young_tree",mature="mature_tree"})[stage] end
local wood,part=(k or ""):match("^([a-z]+)_(leaves)$")
local wood,part=(k or ""):match("^([a-z_]+)_(leaves)$")
if species[wood] then return part end
wood,part=(k or ""):match("^([a-z]+)_(log)$")
wood,part=(k or ""):match("^([a-z_]+)_(log)$")
return species[wood] and part or k
end
function D.is_worldgen_flora(value) return flora[kind(value)]==true or D.tree_info(value)~=nil end
function D.is_worldgen_flora(value)
return flora[kind(value)]==true or D.tree_info(value)~=nil
or (hearth.botany and hearth.botany.kind(value)~=nil and D.family(value)~="log" and D.family(value)~="leaves"
and not (kind(value) or ""):match("_planks$")) or false
end
function D.is_ground(value) return D.grounds[kind(value)]==true end
function D.is_rock(value) return D.rocks[kind(value)]==true end
function D.supports_ground(value, support)
@@ -39,7 +43,9 @@ return function(hearth, api)
alder={alluvium=true,peat=true,forest_floor=true},
beech={grass=true,soil=true,forest_floor=true}, aspen={grass=true,forest_floor=true,alluvium=true},
rowan={grass=true,forest_floor=true,podzol=true,alpine_turf=true},
hazel={grass=true,soil=true,forest_floor=true}, spruce={podzol=true,forest_floor=true,alpine_turf=true}}
hazel={grass=true,soil=true,forest_floor=true}, spruce={podzol=true,forest_floor=true,alpine_turf=true},
crab_apple={grass=true,soil=true,forest_floor=true},wild_cherry={grass=true,soil=true,forest_floor=true},
field_maple={grass=true,soil=true,forest_floor=true}}
function D.supports_species(s, support)
if not species[s] then return false end
local b=hearth.biomes
@@ -86,9 +92,9 @@ return function(hearth, api)
choices=(h.ground=="peat" or (p.x+p.z)%2~=0) and {"alder","willow"} or {"willow","alder"}
elseif h.ground=="forest_floor" and (h.canopy or 0)>=0.5 then choices={"oak","birch"}
else choices={"birch","oak"} end
-- Retain the old pioneer niches at the origin while admitting all ten
-- Retain the old pioneer niches at the origin while admitting all thirteen
-- species deterministically; no random reroll or extra world scan.
local extra=({"beech","aspen","rowan","hazel","spruce"})[math.abs(p.x*7+p.z*11)%10]
local extra=({"beech","aspen","rowan","hazel","spruce","crab_apple","wild_cherry","field_maple"})[math.abs(p.x*7+p.z*11)%13]
if extra and D.suitable(extra.."_seedling",h) then return extra.."_seedling" end
for _,s in ipairs(choices) do if D.suitable(s.."_seedling",h) then return s.."_seedling" end end
end
@@ -150,15 +156,24 @@ return function(hearth, api)
local current=api.get_node_or_nil(p)
if not current or (n and current.name~=n.name) then return false end
n=current
-- Only fallen logs join the existing flora adoption policy; generic
-- litter/fungi and standing log/leaves trees are not newly adopted.
if not n or not (D.is_worldgen_flora(n) or n.name=="hearth:deadwood") or not unmarked(p) then return false end
local root_species=kind(n) and kind(n):match("^([a-z_]+)_log$")
local block_root=(species[root_species] or kind(n)=="log") and hearth.botany~=nil
if not n or not (D.is_worldgen_flora(n) or block_root or n.name=="hearth:deadwood") or not unmarked(p) then return false end
local below={x=p.x,y=p.y-1,z=p.z}
local support=api.get_node_or_nil(below)
if not support or not D.supports_ground(n,support) or not unmarked(below) then return false end
if block_root and not D.is_ground(support) then return false end
local m=api.get_meta(p)
if m:get_string("succ_natural")=="1" then return false end
m:set_string("succ_natural","1")
if block_root then
m:set_string("succ_component",p.x..":"..p.y..":"..p.z)
m:set_string("succ_worldgen_stage","mature")
m:set_string("succ_species",root_species or "generic")
end
-- Succession adopts a known displayed stage on its first bounded visit;
-- bootstrap does not backdate an invented historical birthday.
if block_root or (hearth.botany and hearth.botany.kind(n)) then m:set_string("succ_pending_origin","worldgen_established") end
return true
end
local seasons=hearth.seasons
@@ -220,7 +235,14 @@ return function(hearth, api)
groups={snappy=3,flora=1,flammable=2,hearth_ecosystem=1},drop="",after_place_node=placed})
end
local names={"hearth:deadwood"}
if hearth.botany then
names[#names+1]="hearth:log"
for _,s in ipairs(D.species_order) do names[#names+1]="hearth:"..s.."_log" end
end
for k in pairs(flora) do names[#names+1]="hearth:"..k end
if hearth.botany then for _,r in ipairs(hearth.botany.list()) do
if r.group~="trees" and not flora[r.id] then names[#names+1]=r.node end
end end
for _,s in ipairs(D.species_order) do for _,stage in ipairs({"seedling","young","mature"}) do names[#names+1]="hearth:"..s.."_"..stage end end
if seasons then for _,n in ipairs(seasons.names()) do if D.is_worldgen_flora(n) then names[#names+1]=n end end end
api.register_lbm({name="hearth:diversity_natural_flora",label="Mark unmodified species worldgen flora",
+77 -37
View File
@@ -1,7 +1,8 @@
-- Bounded, restart-safe ecology for perennial forage, saplings and seasonal crops.
return function(hearth, api, construction)
local DAY, MAX_CATCHUP = 86400, 30 * 86400
local CROP_STEP, CROP_MATURE_HOURS = 21600, 60
local DAY, YEAR = 86400, 365.2425*86400
-- Unidentified game grain, not a researched named cereal cultivar.
local CROP_STEP, CROP_MATURE_HOURS, CROP_SAMPLES = 21600, 120*24, 366
local function finite(value)
value = tonumber(value)
if not value or value ~= value or value == math.huge or value == -math.huge then return nil end
@@ -28,25 +29,26 @@ return function(hearth, api, construction)
planted, now = finite(planted), finite(now)
if not planted or not now or planted < 0 or now < 0 then return 0 end
planted, now = math.floor(planted), math.floor(now)
now = math.min(math.floor(now), planted + MAX_CATCHUP)
if planted <= 0 or now <= planted then return 0 end
local progress, cursor = 0, planted
while cursor < now and progress < CROP_MATURE_HOURS do
local finish = math.min(now, cursor + CROP_STEP)
local sample = cursor + math.floor((finish - cursor) / 2)
-- Bounded midpoint quadrature covers the ENTIRE calendar interval. Long
-- gaps coarsen samples, never silently discard all age after day thirty.
local duration=now-planted
local samples=math.min(CROP_SAMPLES,math.max(1,math.ceil(duration/DAY)))
local progress=0
for i=1,samples do
local sample=planted+math.floor(duration*(i-.5)/samples)
local climate = hearth.climate_at and hearth.climate_at(sample)
local temperature = finite(climate and climate.temperature) or 12
local rate = temperature < 3 and 0 or (temperature < 10 and 0.5 or 1)
progress = progress + (finish - cursor) / 3600 * rate
cursor = finish
progress = progress + duration/samples/3600*rate
end
return math.min(CROP_MATURE_HOURS, progress)
return math.min(CROP_MATURE_HOURS,progress,duration/3600)
end
local function crop_stage(planted, now)
local growth = climate_progress(planted, now)
if growth >= CROP_MATURE_HOURS then return 4 end
if growth >= 36 then return 3 end
if growth >= 12 then return 2 end
if growth >= 72*24 then return 3 end
if growth >= 24*24 then return 2 end
return 1
end
local function update_crop(pos)
@@ -54,8 +56,14 @@ return function(hearth, api, construction)
if not current or not current.name:match("^hearth:crop_[1234]$") then return false end
local meta, now = api.get_meta(pos), safe_now()
local planted = tonumber(meta:get_string("planted_at")) or 0
if planted <= 0 or planted > now then meta:set_string("planted_at", tostring(now)); planted = now end
if planted <= 0 or planted > now then return true end
local observed=finite(meta:get_string("crop_last_observed_at")) or planted
if observed>now then return true end
meta:set_string("crop_last_observed_at",tostring(now))
local stage = crop_stage(planted, now)
-- Quadrature refinement is approximate, so an established stage never
-- regresses when a different sample partition straddles a cold front.
stage=math.max(stage,tonumber(current.name:match("(%d)$")))
local target = "hearth:crop_" .. stage
if current.name ~= target then api.swap_node(pos, {name = target, param2 = current.param2}) end
return stage < 4
@@ -98,28 +106,37 @@ return function(hearth, api, construction)
api.get_meta(pos):set_string("player_placed", "1")
end
local function harvest_berries(pos, player)
if not construction.allowed(pos, player, "use") then return end
if not construction.allowed(pos, player, "use") then return false end
local n = node(pos)
if not n then return end
if not n or (n.name~="hearth:berry_bush" and n.name~="hearth:berry_bush_bare") then return false end
if n.name == "hearth:berry_bush_bare" then
hearth.notify(player, "The berry bush is regrowing; return after one real day.")
return
hearth.notify(player, "The berry bush has spent this crop; check a future Summer after its annual rest.")
return false
end
local meta,now=api.get_meta(pos),safe_now()
local previous=meta:get_string("ready_at")
local due=previous=="" and 0 or finite(previous)
if not due or due<0 or due>now or now>9007199254740991-YEAR then return false end
local inv = player:get_inventory()
local output = ItemStack("hearth:berries 3")
if not inv or not inv:room_for_item("main", output) then
hearth.notify(player, "Make inventory space before picking these berries.")
return
return false
end
local requested = output:get_count()
-- Shared durable reservation before inventory callbacks, including
-- reentrant picking. A rejected transfer restores exactly the old field.
local ready = now + YEAR
meta:set_string("ready_at",tostring(ready))
local leftover = inv:add_item("main", output)
if not leftover or leftover:get_count() >= requested then return end
api.set_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2})
local ready = safe_now() + DAY
api.get_meta(pos):set_string("ready_at", tostring(ready))
api.get_meta(pos):set_string("infotext", "Berry bush: regrowing (one real day)")
local count=leftover and finite(leftover:get_count())
if not count or count<0 or count>requested then return false end -- uncertain delivery stays locked
if count==requested then meta:set_string("ready_at",previous); return false end
api.swap_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2})
meta:set_string("infotext", "Berry bush: annual rest; next eligible Summer")
api.get_node_timer(pos):start(DAY)
if hearth.invalidate_environment then hearth.invalidate_environment(pos) end
return true
end
local function harvest_seasonal(pos, player)
if not construction.allowed(pos, player, "use") then return false end
@@ -129,25 +146,30 @@ return function(hearth, api, construction)
local item = ({spring_greens_patch="hearth:spring_greens", autumn_mushrooms_patch="hearth:autumn_mushrooms"})[kind]
if not item then return false end
local now, meta = safe_now(), api.get_meta(pos)
local ready = finite(meta:get_string("succ_harvest_ready_at")) or 0
if not seasons.available(kind, now) or ready > now then
local previous=meta:get_string("succ_harvest_ready_at")
local ready = previous=="" and 0 or finite(previous)
if not ready or ready<0 or not seasons.available(kind, now) or ready > now then
hearth.notify(player, "This patch rests outside its season or until one real day after picking.")
return false
end
local inv, output = player:get_inventory(), ItemStack(item)
if not inv or not inv:room_for_item("main", output) then return false end
-- One item only: no partial multi-item harvest can lose or duplicate food.
local leftover = inv:add_item("main", output)
if not leftover or leftover:get_count() ~= 0 then return false end
meta:set_string("succ_harvest_ready_at", tostring(now + DAY))
local leftover = inv:add_item("main", output)
local count=leftover and finite(leftover:get_count())
if count==1 then meta:set_string("succ_harvest_ready_at",previous); return false end
if count~=0 then return false end
return true
end
local function refresh_bush(pos)
local n = node(pos)
if not n or n.name ~= "hearth:berry_bush_bare" then return false end
local meta, now = api.get_meta(pos), safe_now()
local ready = tonumber(meta:get_string("ready_at")) or 0
if ready <= now then
local ready = finite(meta:get_string("ready_at"))
if not ready or ready<0 then return false end
local climate=hearth.climate_at and hearth.climate_at(now)
if ready <= now and climate and climate.season=="Summer" then
api.swap_node(pos, {name = "hearth:berry_bush", param2 = n.param2})
meta:set_string("ready_at", "0"); meta:set_string("infotext", "")
api.get_node_timer(pos):stop()
@@ -158,8 +180,7 @@ return function(hearth, api, construction)
local grow_sapling
local function reconcile_sapling(pos)
if grow_sapling(pos) then
local planted = tonumber(api.get_meta(pos):get_string("planted_at")) or safe_now()
api.get_node_timer(pos):start(math.max(60, math.min(DAY, planted + 3 * DAY - safe_now())))
api.get_node_timer(pos):start(DAY)
end
end
local function reconcile_bush(pos)
@@ -187,17 +208,36 @@ return function(hearth, api, construction)
if not n or n.name ~= "hearth:sapling" then return false end
local meta, now = api.get_meta(pos), safe_now()
local planted = tonumber(meta:get_string("planted_at")) or 0
if planted <= 0 or planted > now then meta:set_string("planted_at", tostring(now)); planted = now end
if planted <= 0 or planted > now then return true end
local previous=finite(meta:get_string("succ_last_observed_at")) or planted
if previous>now then return true end
meta:set_string("succ_birth_at",tostring(planted))
meta:set_string("succ_last_observed_at",tostring(now))
meta:set_string("succ_lifecycle_version","1")
meta:set_string("succ_lifecycle_origin","player_planted")
meta:set_string("succ_lifecycle_profile","generic_tree")
meta:set_string("succ_lifecycle_policy","generic_1y_young_10y_mature_unknown_lifespan")
local owner = meta:get_string("planter")
if now - planted < 3 * DAY or not tree_clear(pos, owner) then return true end
for y = 0, 3 do api.set_node({x=pos.x,y=pos.y+y,z=pos.z},{name="hearth:log"}) end
if now - planted < YEAR or not tree_clear(pos, owner) then return true end
local function plant_wood(q,name)
api.set_node(q,{name=name}); api.get_meta(q):set_string("player_placed","1")
end
for y = 0, 3 do plant_wood({x=pos.x,y=pos.y+y,z=pos.z},"hearth:log") end
for y = 3, 5 do for x = -2, 2 do for z = -2, 2 do
local radius = y == 5 and 1 or 2
if math.abs(x) + math.abs(z) <= radius and not (x == 0 and z == 0 and y < 5) then
local p = {x=pos.x+x,y=pos.y+y,z=pos.z+z}
if api.get_node(p).name == "air" then api.set_node(p, {name="hearth:leaves"}) end
if api.get_node(p).name == "air" then plant_wood(p,"hearth:leaves") end
end
end end end
-- These are player trees, not a newly generated natural forest. Mark
-- every written cell so later worldgen adoption cannot erase provenance.
local root=api.get_meta(pos)
root:set_string("planted_at",tostring(planted)); root:set_string("planter",owner)
root:set_string("succ_birth_at",tostring(planted)); root:set_string("succ_last_observed_at",tostring(now))
root:set_string("succ_lifecycle_version","1"); root:set_string("succ_lifecycle_origin","player_planted")
root:set_string("succ_lifecycle_profile","generic_tree")
root:set_string("succ_lifecycle_policy","generic_1y_young_10y_mature_unknown_lifespan")
return false
end
local function plant_sapling(stack, player, pointed)
@@ -207,7 +247,7 @@ return function(hearth, api, construction)
api.set_node(pos, {name = "hearth:sapling"})
local meta = api.get_meta(pos)
meta:set_string("planted_at", tostring(safe_now())); meta:set_string("planter", player:get_player_name())
api.get_node_timer(pos):start(3 * DAY)
api.get_node_timer(pos):start(DAY)
stack:take_item(1)
return stack
end
@@ -226,5 +266,5 @@ return function(hearth, api, construction)
refresh_bush = refresh_bush, reconcile_bush = reconcile_bush,
reconcile_crop = reconcile_crop, plant_sapling = plant_sapling,
grow_sapling = grow_sapling, reconcile_sapling = reconcile_sapling,
MAX_CATCHUP = MAX_CATCHUP, CROP_MATURE_HOURS = CROP_MATURE_HOURS}
CROP_SAMPLE_LIMIT = CROP_SAMPLES, CROP_MATURE_DAYS = 120, CROP_MATURE_HOURS = CROP_MATURE_HOURS}
end
+115
View File
@@ -0,0 +1,115 @@
-- Read-only catalogue factory. Biology comes from lifecycle, never game ticks.
return function(hearth, api)
local F={VERSION=1,SECONDS_PER_DAY=86400}
local function copy(t)
if type(t)~="table" then return t end
local r={}; for k,v in pairs(t) do r[k]=copy(v) end; return r
end
local function set(s) local r={}; for id in s:gmatch("%S+") do r[id]=true end; return r end
local lifecycle=hearth.lifecycle
if not lifecycle and api.get_modpath then lifecycle=dofile(api.get_modpath("hearth").."/lifecycle.lua") end
-- Bind-pose bounds measured from faunaBounds(), unitsPerNode=10. Runtime
-- collision uses a conservative yaw-independent envelope plus animation margin.
-- id, Latin identity (false for legacy deer), medium, food, activity, cap,
-- speed (presentation policy, nodes/s), width radius, height, rear, front.
local rows={
{"hare","Lepus europaeus","land","browse","night",6,1.3,.1375,.814,.3415,.37004},
{"deer",false,"land","browse","crepuscular",3,1,.31625,1.5947,.683,.65081},
{"fox","Vulpes vulpes","land","predator fruit","night",3,1.2,.15,.6856,.8624,.5028},
{"rabbit","Oryctolagus cuniculus","land","browse","crepuscular",6,1,.154,.5509,.25954,.31371},
{"red_squirrel","Sciurus vulgaris","land","seed fruit","day",4,.9,.075,.5924,.46744,.24266},
{"bank_vole","Myodes glareolus","land","browse seed fruit","any",6,.55,.07,.196,.17039,.17507},
{"wood_mouse","Apodemus sylvaticus","land","seed fruit","night",6,.6,.06,.2386,.31045,.162505},
{"hedgehog","Erinaceus europaeus","land","predator","night",3,.35,.11,.25,.21173,.23755},
{"badger","Meles meles","land","predator fruit","night",2,.65,.19,.4924,.45078,.475885},
{"roe_deer","Capreolus capreolus","land","browse","crepuscular",3,1,.22375,1.2285,.49176,.48443},
{"stoat","Mustela erminea","land","predator","any",2,1.1,.06,.2462,.4514625,.269535},
{"weasel","Mustela nivalis","land","predator","any",2,.85,.0425,.1808,.26846,.19111},
{"otter","Lutra lutra","amphibian","predator","night",2,.9,.125,.3884,.70711,.39778},
{"robin","Erithacus rubecula","fly","predator fruit seed","day",4,.8,.1646,.3721,.21895,.173875},
{"blackbird","Turdus merula","fly","predator fruit","day",4,.8,.1844,.4175,.30653,.2014},
{"blue_tit","Cyanistes caeruleus","fly","seed fruit","day",4,.9,.125,.3167,.17516,.13705},
{"barn_owl","Tyto alba","fly","predator","night",2,1.2,.2834,.6434,.2736875,.275},
{"mallard","Anas platyrhynchos","amphibian","seed browse fruit","day",4,.65,.2735,.4704,.4379,.2555},
{"common_frog","Rana temporaria","amphibian","predator","any",4,.3,.099,.1475,.15026,.22272},
{"smooth_newt","Lissotriton vulgaris","amphibian","predator","night",4,.16,.04,.1014,.3423125,.162945},
{"brown_trout","Salmo trutta","swim","predator","any",4,.65,.1045,.3045,.3552,.24},
{"bumblebee","Bombus terrestris","fly","nectar","day",6,.65,.144,.1225,.065,.1064},
{"earthworm","Lumbricus terrestris","invertebrate","litter","night",8,20/3600,.02,.04,.191266666666667,.191266666666667},
}
local rest_policy={
day={kind="solar_day",sleep_below=-6,winddown_below=6},
night={kind="solar_night",sleep_above=6,winddown_above=-6},
crepuscular={kind="twilight",active_between={-8,8},rest_hours={11,15}},
any={kind="flexible",rest_hours={12,14},winddown_hours={11.5,14.5}},
}
local winter_dormant=set("hedgehog common_frog smooth_newt")
local prey={fox="hare rabbit bank_vole wood_mouse red_squirrel earthworm common_frog",
stoat="rabbit bank_vole wood_mouse",weasel="bank_vole wood_mouse robin blue_tit",
barn_owl="bank_vole wood_mouse",badger="earthworm bank_vole wood_mouse hedgehog",
hedgehog="earthworm",otter="brown_trout common_frog smooth_newt",
robin="earthworm",blackbird="earthworm",common_frog="earthworm",smooth_newt="earthworm",
-- No imaginary mayfly/larva population and no adult-trout cannibalism.
brown_trout=""}
local index,order={},{}
local clips={idle={0,19},locomotion={20,39},feed={40,59},flight_swim={60,79},rest={80,99}}
for _,v in ipairs(rows) do
local id=v[1]; local profile=lifecycle and lifecycle.animal(id) or false
local url="https://en.wikipedia.org/wiki/"..(v[2] and v[2]:gsub(" ","_") or "Deer")
local r={id=id,kind="animal",entity="hearth:"..id,common=id:gsub("_"," "),latin=v[2],
medium=v[3],foods=set(v[4]),activity=v[5],cap=v[6],speed=v[7],
bounds={min={-v[8],0,-v[10]},max={v[8],v[9],v[11]}},
mesh="hearth_fauna_"..id..".b3d",texture="hearth_fauna_"..id..".png",
clips=copy(clips),fps=24,frames=100,eye_height=math.max(.15,v[9]*.8),
prey=set(prey[id] or ""),profile=profile,source_url=url,wikipedia_url=url,
sleep=copy(rest_policy[v[5]] or rest_policy.crepuscular),
winter_dormant=winter_dormant[id]==true,
sources={url},confidence=id=="deer" and "unverified_identity" or "species_account",
-- Numeric feeding cadence/reserve is a coarse energy accounting policy,
-- NOT a claimed species metabolic/starvation measurement.
feed_seconds=(id=="bumblebee" and 300 or (prey[id] and 7200 or 3600)),
reserve_seconds=2*86400,starvation_days=false}
if profile and profile.source_url then r.sources[#r.sources+1]=profile.source_url end
if id=="rabbit" then
r.starvation_days=8; r.starvation_source=url.."#Diet"
end
index[id]=r; order[#order+1]=id
end
function F.kind(value)
local n=type(value)=="table" and value.name or value
if type(n)~="string" or #n>80 then return nil end
n=n:gsub("^hearth:",""); return index[n] and n or nil
end
function F.get(value) return copy(index[F.kind(value)]) end
function F.list() local out={}; for _,id in ipairs(order) do out[#out+1]=copy(index[id]) end; return out end
F.catalog=F.list
function F.source(value) local r=index[F.kind(value)]; return r and copy(r.sources) end
function F.phase(id,born,now)
local r=index[F.kind(id)]
return r and r.profile and lifecycle and lifecycle.phase(r.profile,born,now) or nil
end
function F.breeding_window(id,month)
local r=index[F.kind(id)]
return r and r.profile and lifecycle and lifecycle.breeding_window(r.profile,month) or nil
end
function F.can_breed(id,born,now,sex,current_season)
local r=index[F.kind(id)]; local p=r and r.profile
if not p or p.reproduction_enabled~=true then return false,"incomplete_profile" end
if sex~="female" and sex~="male" then return false,"sex" end
local development=p.reproduction=="egg" and "incubation_days" or "gestation_days"
for _,field in ipairs({"maturity_days",development,"juvenile_days","birth_count"}) do
local t=p[field]
if type(t)~="table" or t.confidence~="source_reported" or not t.source_url then return false,"incomplete_profile" end
end
local phase=F.phase(id,born,now)
if not phase then return false,"age" end
if current_season==nil and hearth.climate_at then
local c=hearth.climate_at(now); current_season=c and c.season
end
-- Climate's season already accounts for the configured hemisphere. Do
-- not intersect it with northern UK months (which breaks southern spring).
return phase.phase=="mature" and type(p.breeding_seasons)=="table"
and p.breeding_seasons[current_season]==true or false
end
return F
end
+269 -25
View File
@@ -1,5 +1,10 @@
local C = core
local esc = C.formspec_escape
local function tooltip(name,text)
-- Luanti 5.18 requires both tooltip colors when the optional color slots are
-- parsed. Supplying the Mocha pair also keeps high-density creator menus legible.
return "tooltip[" .. esc(name) .. ";" .. esc(text) .. ";#181825;#cdd6f4]"
end
-- eom.dev Mocha anchors: shared by every journal surface, not terrain tints.
local ink = {base="#1e1e2e", mantle="#181825", raised="#292940", border="#3c3d56",
text="#cdd6f4", muted="#a6adc8", mauve="#cba6f7", teal="#94e2d5", yellow="#f9e2af", danger="#f38ba8"}
@@ -50,11 +55,11 @@ local pages = {
{"Your homestead", "MAKE A PLACE YOURS", "A community without compulsory chores.",
"Use /claim at your homestead to protect a 25 x 25 area, extending 24 blocks up/down. Use /claim show for temporary boundary markers. Share with /claim share <player> resident (build and household use) or /claim share <player> storekeeper (storage only). /claim unshare <player> revokes access; /claim list shows members; /claim remove releases the claim and grants.\n\nPlace homestead storage inside a claim. Its inventory stays private to the owner, residents and storekeepers; non-empty storage cannot be removed. Workstations and construction require resident permissions.\n\nUse /sethome in your own safe claim; /home rechecks that position before moving you and safely falls back to the world arrival if the home is blocked or hazardous. Your inventory, food, water and wetness persist through death.\n\nLogged-out player needs pause. Hearth processes and fuel use real elapsed time; completed output remains stored until claimed. Food does not spoil in this slice. This remains a prototype, not a hardened public service."},
{"Living ecosystems", "NOTHING GROWS ALONE", "Watch a meadow change; there is no quest to complete.",
"An original succession model inspired by Dynamic Life connects light, moisture, plants and animals. The botanical catalogue has 80 named species: eight categories of ten each. Use the Botanical guide button or /botany [search] for common and scientific names, habitat, seasons, toxicity labels and roles. These are game-only notes, not a real-world forage guide.\n\nBirch pioneers open gaps; oak favors meadow/forest soils. Evergreen pine and spruce keep winter shade; deciduous oak, birch, willow, alder, beech, aspen, rowan and hazel thin in Winter. Willow favors wet alluvium/peat. Nitrogen-fixer policy can add at most two nutrient units per support across replacements/restarts while reserves are low. The legacy alder terrain profile alone is not a separate fertilizer process. Litter, fungi, deadwood and dung recycle finite nutrients. Fallen logs are ordinary sideways cubes: an axe yields one log, not hand-gathered sticks.\n\nSpring/Summer clover and flowers support pollinators; spring bluebells and autumn heather have seasonal windows. Wet sedge/bulrush, shade moss/ferns, rock lichen and nutrient-needing nettle have distinct niches. The survey counts supporting plants, not insects. Hares and deer graze natural cover; foxes pursue hares. Use /ecosystem for actual ground, light, moisture, canopy, nutrient reserve and active wildlife totals. Moisture is a bounded habitat estimate, not fully simulated hydrology, erosion or flooding.\n\nRoles are conditional policies or descriptors, not effects guaranteed for every species: parasitism and allelopathy cause no plant damage, and a resin tag is not an extraction recipe. Wild succession advances only around connected players in loaded areas. It pauses across unloading and restart, with no offline catch-up. Claims, placed plants, crops and construction are excluded from autonomous changes. Stages take active sessions, not a daily login. Crops and planted saplings keep separate growth rules."},
"An original succession model inspired by Dynamic Life connects light, moisture, plants and animals. The botanical catalogue has 100 named species in eight uneven ecological groups, not eight groups of ten. Use /botany [search] for names, habitat, seasons, toxicity labels and roles; /fauna lists 23 animal identities with sources and lifecycle status. These are game-only notes, not a real-world forage guide.\n\nBirch pioneers open gaps; oak favors meadow/forest soils. Evergreen pine and spruce keep winter shade; deciduous trees including crab apple, wild cherry and field maple thin in Winter. Willow favors wet alluvium/peat. Nitrogen-fixer policy can add at most two nutrient units per support after 90 real calendar days, across replacements/restarts while reserves are low. The legacy alder terrain profile alone is not a separate fertilizer process. Litter, fungi, deadwood and dung recycle finite nutrients. Fallen logs are ordinary sideways cubes: an axe yields one log, not hand-gathered sticks.\n\nSpring/Summer clover and flowers support pollinators; spring bluebells and autumn heather have seasonal windows. Wet sedge/bulrush, shade moss/ferns, rock lichen and nutrient-needing nettle have distinct niches. The plant survey counts supporting plants, not insects; wildlife counts include every active species. Animals use finite browse, nectar, fruit, seeds, litter or modeled prey. Use /ecosystem for observations. Moisture is a bounded habitat estimate, not fully simulated hydrology, erosion or flooding.\n\nRoles are conditional policies or descriptors: parasitism and allelopathy cause no plant damage; a resin tag is not an extraction recipe. Calendar ages include unloaded time. Ecological work advances only in loaded areas with no offline catch-up or missed-event replay. Claims, placed plants, crops and construction are excluded from autonomous changes. Named tree stages use real years to decades, not active sessions; fungi do not accelerate growth. Source-reported fields, policy fallbacks and UNKNOWN are distinct. All incomplete animal profiles disable breeding; initial established wildlife are immigration, not adult births. Unknown age and lifespan endpoints never authorize automatic death."},
{"Clock & location", "ONE WORLD. ONE CLOCK.", "A real calendar, not a daily login requirement.",
"Local play asks you to choose approximate coordinates. No location service, IP lookup or external request is used. Use /location to change them. Coordinates stay in local world storage.\n\nA community server must set its shared latitude and longitude in configuration. Remote players cannot override them. The world follows the host's real UTC clock.\n\nSun height and day length follow latitude, longitude and date. The engine's fixed sky orbit is approximated; it is not an exact astronomical renderer. The HUD uses UTC and local solar time, not civil timezone labels.\n\nClimate and rain are synthetic. Calendar-season labels use meteorological months; southern latitudes reverse them."},
{"Geology & timber", "READ THE REGIONAL MATERIALS", "Ten biomes; shared sky, different ground.",
"Temperate meadow and oak woodland favor limestone; birch forest and pine taiga favor granite. Wet forest has peat over slate; floodplain has alluvium over sandstone. Dry scrub has sandstone, alpine meadow has turf/scree over slate, rocky peaks have snow/scree over granite, and riverbank has sand/gravel over sandstone. These are ten terrain communities, not ten independent weather simulations. The climate HUD remains shared; altitude still affects exposure temperature.\n\nQuartz seams occur in granite/slate; copper in basalt/limestone/slate up to Y=32; coal in sandstone/slate/limestone up to Y=70. Deeper beds and basalt intrusions differ from surface rock. Mine with a pick; each seam drops two resource items. Three quartz craft one existing window, without a glass furnace.\n\nOne copper ore + one charcoal craft one copper charge. At a lit hearth it takes 45 real seconds and reserves 30 fuel seconds, yielding one ingot. Three copper ingots across the top row, with two sticks down the center, make one faster, longer-lasting copper pick. Iron remains a separate 60-second smelt and axe route.\n\nOak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each burn for 180 seconds, or craft four matching planks. Convert two matching species logs to two canonical logs for log + clay charcoal charges; convert species planks 1:1 to canonical planks for exact-name building recipes. One regional rock crafts four matching bricks; two matching rocks convert to two canonical stones. Put each input in separate crafting slots (shapeless); there is no single-log or single-rock canonical conversion. Fallen logs are sideways full cubes; an axe yields one canonical log, never two hand-gathered sticks. Coal burns 240 seconds; charcoal 90; sticks 45. All fuel is capped at 600 seconds. Coal is geological and nonrenewable, not a renewable fuel loop.\n\nThe broad-relief terrain preset is for fresh worlds only. Existing chunks stay untouched; new biome registrations can change unexplored old-world boundaries. Operators must explicitly choose a new world or accept those seams; never delete map metadata to force a migration."},
"Temperate meadow and oak woodland favor limestone; birch forest and pine taiga favor granite. Wet forest has peat over slate; floodplain has alluvium over sandstone. Dry scrub has sandstone, alpine meadow has turf/scree over slate, rocky peaks have snow/scree over granite, and riverbank has sand/gravel over sandstone. These are ten terrain communities, not ten independent weather simulations. The climate HUD remains shared; altitude still affects exposure temperature.\n\nQuartz seams occur in granite/slate; copper in basalt/limestone/slate up to Y=32; coal in sandstone/slate/limestone up to Y=70. Deeper beds and basalt intrusions differ from surface rock. Mine with a pick; each seam drops two resource items. Three quartz craft one existing window, without a glass furnace.\n\nOne copper ore + one charcoal craft one copper charge. At a lit hearth it takes 45 real seconds and reserves 30 fuel seconds, yielding one ingot. Three copper ingots across the top row, with two sticks down the center, make one faster, longer-lasting copper pick. Iron remains a separate 60-second smelt and axe route.\n\nOak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce, crab apple, wild cherry and field maple logs each burn for 180 seconds, or craft four matching planks. Convert two matching species logs to two canonical logs for log + clay charcoal charges; convert species planks 1:1 to canonical planks for exact-name building recipes. One regional rock crafts four matching bricks; two matching rocks convert to two canonical stones. Put each input in separate crafting slots (shapeless); there is no single-log or single-rock canonical conversion. Fallen logs are sideways full cubes; an axe yields one canonical log, never two hand-gathered sticks. Coal burns 240 seconds; charcoal 90; sticks 45. All fuel is capped at 600 seconds. Coal is geological and nonrenewable, not a renewable fuel loop.\n\nThe broad-relief terrain preset is for fresh worlds only. Existing chunks stay untouched; new biome registrations can change unexplored old-world boundaries. Operators must explicitly choose a new world or accept those seams; never delete map metadata to force a migration."},
}
local botanical_groups = {
{"trees", "Trees"}, {"shrubs", "Shrubs"}, {"grasses", "Grasses, sedges & rushes"},
@@ -68,7 +73,7 @@ end
local function botanical_summary()
local records, lines = catalogue(), {}
if #records == 0 then return "Botanical catalogue unavailable in this runtime. Use /botany when installed." end
lines[1] = "BOTANICAL CATALOGUE / " .. #records .. " named species; eight categories of ten in the full catalogue.\nUse /botany [search] for scientific names, habitat windows, seasons and ecological roles."
lines[1] = "BOTANICAL CATALOGUE / " .. #records .. " named species; eight ecological groups of uneven sizes.\nUse /botany [search] for scientific names, habitat windows, seasons and ecological roles; /fauna for 23 animal identities and lifecycle evidence."
for _, group in ipairs(botanical_groups) do
local names = {}
for _, r in ipairs(records) do
@@ -99,11 +104,12 @@ function hearth.guide(player, page)
local notes = p[4] .. (page == 6 and "\n\n" .. botanical_summary() or "")
f[#f + 1] = "textarea[3.8,2.65;9.6,5.6;;;" .. esc(notes) .. "]"
f[#f + 1] = "button[3.85,8.15;2.3,0.55;inspect_target;Inspect target]"
f[#f + 1] = "button[6.3,8.15;2.1,0.55;fauna_index;Fauna guide]"
f[#f + 1] = "button[3.85,8.85;2.3,0.65;ecosystem_survey;Ecosystem survey]"
.. "tooltip[ecosystem_survey;Read a loaded habitat patch and global active wildlife. No world changes.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("ecosystem_survey","Read a loaded habitat patch and global active wildlife. No world changes.")
f[#f + 1] = "button[6.3,8.85;2.1,0.65;botany_index;Botanical guide]"
f[#f + 1] = "button[8.55,8.85;2.1,0.65;recipe_index;Recipe index]"
.. "tooltip[recipe_index;Search ingredients, results and real-time processes.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("recipe_index","Search ingredients, results and real-time processes.")
f[#f + 1] = "button_exit[10.8,8.85;2.5,0.65;close;Back to the valley]"
C.show_formspec(player:get_player_name(), "hearth:guide", table.concat(f))
end
@@ -112,6 +118,7 @@ local recipe_outputs = {"hearth:knife", "hearth:axe", "hearth:pick", "hearth:spe
"hearth:copper_charge", "hearth:copper_pick", "hearth:log", "hearth:stone",
"hearth:oak_planks", "hearth:birch_planks", "hearth:pine_planks", "hearth:willow_planks", "hearth:alder_planks",
"hearth:beech_planks", "hearth:aspen_planks", "hearth:rowan_planks", "hearth:hazel_planks", "hearth:spruce_planks",
"hearth:crab_apple_planks", "hearth:wild_cherry_planks", "hearth:field_maple_planks",
"hearth:granite_bricks", "hearth:basalt_bricks", "hearth:limestone_bricks", "hearth:slate_bricks", "hearth:sandstone_bricks",
"hearth:firestarter", "hearth:vessel", "hearth:torch", "hearth:fire", "hearth:wall",
"hearth:roof", "hearth:foundation", "hearth:frame", "hearth:stairs", "hearth:door",
@@ -127,8 +134,8 @@ local process_notes = {
{"Smelting", "One iron ore/charcoal charge + lit hearth: 60 real seconds; 30 fuel seconds reserved at start. Copper uses its separate 45-second charge."},
{"Copper smelting", "One hearth:copper_charge (one hearth:copper_ore + one hearth:charcoal) + lit hearth: 45 real seconds; 30 fuel seconds reserved at start; one hearth:copper_ingot output. Requires at least 75 fuel seconds to queue. Collect empty-handed; a full inventory leaves the ingot waiting. Copper pick: three ingots across the top and two central sticks."},
{"Regional geology", "Quartz in granite/slate: three hearth:quartz craft one window; the reed/plank route remains. Copper ore in basalt/limestone/slate; coal in sandstone/slate/limestone. Each seam drops two items. One granite, basalt, limestone, slate or sandstone crafts four matching bricks. Convert two matching rocks to two canonical stones in separate crafting slots (shapeless); no single-rock canonical conversion."},
{"Species timber", "One oak, birch, pine, willow, alder, beech, aspen, rowan, hazel or spruce log crafts four matching planks. Convert two matching species logs in separate crafting slots (shapeless) to two canonical hearth:log; no single-log canonical conversion. Species planks convert 1:1 to canonical hearth:planks. Charcoal charges accept only canonical log + clay: convert species logs in matching pairs first, not four planks back into logs."},
{"Finite nitrogen", "Nitrogen-fixer policy, including clover and alder in the botanical runtime, can add at most two nutrient units per support across replacements and restarts while the reserve is low. The legacy alder terrain profile alone is not a fertilizer process. Nutrient reserve is bounded at eight; litter, deadwood, fungi and dung recycle finite material."},
{"Species timber", "One oak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce, crab_apple, wild_cherry or field_maple log crafts four matching planks. Convert two matching species logs in separate crafting slots (shapeless) to two canonical hearth:log; no single-log canonical conversion. Species planks convert 1:1 to canonical hearth:planks. Charcoal charges accept only canonical log + clay: convert species logs in matching pairs first, not four planks back into logs."},
{"Finite nitrogen", "After 90 real calendar days, nitrogen-fixer policy, including clover and alder in the botanical runtime, can add at most two nutrient units per support across replacements and restarts while the reserve is low and conditions are warm. This cadence is game policy, not a measured species rate. The legacy alder terrain profile alone is not a fertilizer process. Nutrient reserve is bounded at eight; litter, deadwood, fungi and dung recycle finite material."},
{"Fallen logs", "Ordinary full-cube fallen logs lie sideways. Use an axe to harvest one canonical hearth:log; hands do not yield sticks. They also support decomposer habitat. Player-placed and claimed material is excluded from autonomous succession."},
{"Pottery firing", "One shaped clay-jar charge + lit hearth: 120 real seconds; 30 fuel seconds reserved at start."},
{"Food drying", "One cooked-food/reed charge + lit hearth: 300 real seconds; 30 fuel seconds reserved at start."},
@@ -136,11 +143,11 @@ local process_notes = {
{"Fish preparation", "Roast one raw fish at a lit hearth for 8 real seconds; reserve 20 fuel seconds. Combine cooked fish with reeds into a drying charge, then dry for 300 seconds with 30 fuel seconds reserved. Dried fish is shelf-stable."},
{"Spring fish run", "A trap finishing during spring returns two fish; other seasons return one. The catch is determined by its scheduled deadline, even if you are away."},
{"Light a hearth", "Fuel first, then use a flint firestarter. Each starter has 20 successful ignitions."},
{"Fire fuel", "Each stick adds 45 real seconds; canonical, oak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each add 180; charcoal adds 90; coal adds 240 seconds. Stored reserve is capped at 600; a fuel item that would exceed it is not consumed. Planks and buildings are not direct fuel. Coal is geological and nonrenewable; it cannot be crafted or grown."},
{"Fire fuel", "Each stick adds 45 real seconds; canonical, oak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce, crab_apple, wild_cherry and field_maple logs each add 180; charcoal adds 90; coal adds 240 seconds. Stored reserve is capped at 600; a fuel item that would exceed it is not consumed. Planks and buildings are not direct fuel. Coal is geological and nonrenewable; it cannot be crafted or grown."},
{"Charcoal", "Combine one log with river clay, then burn the charge at a lit hearth for 60 seconds with 30 fuel seconds reserved. The resulting charcoal stores 90 seconds; total fuel recovered never exceeds the log's 180 seconds."},
{"Bedroll rest", "Craft from three planks and three reeds. Right-click beneath a roof; remain still and safe for five seconds."},
{"Crop growth", "Plant grain seed on grass or soil. Growth samples historical local solar climate; cold weather pauses it. Harvest ripe crops for grain, then reserve one kernel to replant."},
{"Renewal", "Berry bushes return after one real day. A planted sapling grows after three days when its bounded tree volume is clear. No resource can regrow above map limits or through claims."},
{"Renewal", "Berry crops rest one mean real year (365.2425 days), then require their fruit season; no 24-hour refill or missed-crop replay. Human and animal botanical fruit share one readiness deadline. Pollination adds at most one berry to an existing crop once. A planted generic sapling requires one real year and clear bounded tree volume; named trees use species lifecycle endpoints, often years to decades. Unknown timing remains labelled policy or UNKNOWN, never invented biology. No resource grows through claims or map limits."},
{"Preservation", "Combine cooked food with reeds into a drying charge, then process it at a lit hearth. Dried rations are shelf-stable; general food spoilage is currently off."},
{"Spring fish", "Spring catches can produce two fish per baited trap. Search this index for fish preparation; it is seasonal but not time-critical."},
{"Pottery", "Shape six river clay into an unfired jar, then fire it at a lit hearth. Use the fired jar with planks to make a larger pantry."},
@@ -207,8 +214,8 @@ function hearth.recipes(player, query, page)
.. "box[0.65,2.2;9.3,0.8;" .. ink.mantle .. "]"
.. "field[0.7,2.25;9.2,0.7;query;Result or ingredient (plain text);" .. esc(query) .. "]field_close_on_enter[query;false]"
.. "button[10.25,2.25;3.1,0.7;search;Search]"
.. "tooltip[query;Up to 48 characters. Search matches literal text, not patterns.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. "tooltip[search;Search crafting recipes and real-time hearth processes.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("query","Up to 48 characters. Search matches literal text, not patterns.")
.. tooltip("search","Search crafting recipes and real-time hearth processes.")
.. label(0.7,3.3,"Page " .. page .. " / " .. pages_count .. " / " .. #all .. " matches",ink.muted)
.. "textarea[0.65,3.7;12.7,4.6;;;" .. esc(table.concat(lines, "\n\n")) .. "]"
.. (page > 1 and "button[0.7,8.85;2.8,0.65;previous;Previous]" or "")
@@ -284,7 +291,7 @@ function hearth.botanical_guide(player, query, page)
session.botany_query, session.botany_page = query, page
C.show_formspec(name,"hearth:botany",journal(14,10)
.. label(0.65,0.55,"eom.dev / BOTANICAL FIELD GUIDE",ink.mauve)
.. label(0.65,1.15,#records .. " named species / eight categories of ten in the full catalogue",ink.yellow)
.. label(0.65,1.15,#records .. " named species / eight ecological groups of uneven sizes",ink.yellow)
.. divider(0.65,1.65,12.7)
.. "field[0.7,1.95;9.2,0.7;query;Species, scientific name, category, habitat or role;" .. esc(query) .. "]field_close_on_enter[query;false]"
.. "button[10.25,1.95;3.1,0.7;search;Search]"
@@ -292,13 +299,201 @@ function hearth.botanical_guide(player, query, page)
.. "textarea[0.65,3.3;12.7,4.35;;;" .. esc(table.concat(lines,"\n\n")) .. "]"
.. "textarea[0.65,7.7;12.7,0.55;;;" .. esc("Game-only notes, NOT a real-world forage guide. Unflagged does not mean safe to eat. Roles are conditional, not universal effects.") .. "]"
.. (#choices > 0 and "dropdown[0.7,8.3;5,0.45;species_choice;" .. table.concat(choices,",") .. ";1;true]"
.. "tooltip[species_choice;Species article]button[6,8.3;3.3,0.45;species_article;Species article]" or "")
.. tooltip("species_choice","Species article") .. "button[6,8.3;3.3,0.45;species_article;Species article]" or "")
.. "button[10.05,8.3;3.3,0.45;fauna_index;Fauna guide]"
.. (page > 1 and "button[0.7,8.85;2.2,0.65;previous;Previous]" or "")
.. (page < count and "button[3.05,8.85;2.2,0.65;next;Next]" or "")
.. "button[6,8.85;3.3,0.65;ecosystem_survey;Ecosystem survey]"
.. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
end
-- Observational catalogue, independent of the plant-only article reader.
local function fauna_observations(player, discovered_id)
local F=hearth.fauna
if not F or type(F.list)~="function" or type(player.get_meta)~="function" then return nil end
local allowed={}; for _,record in ipairs(F.list()) do allowed[record.id]=true end
local ok,meta=pcall(function() return player:get_meta() end)
if not ok or not meta or type(meta.get_string)~="function" or type(meta.set_string)~="function" then return nil end
local key="hearth:fauna_observations_v1"
local read_ok,raw=pcall(meta.get_string,meta,key)
if not read_ok or type(raw)~="string" or #raw>512 then return nil end
local seen,ordered={},{}
if raw~="" then
for id in raw:gmatch("[^,]+") do
if not allowed[id] or seen[id] then return nil end
seen[id]=true; ordered[#ordered+1]=id
end
table.sort(ordered)
if table.concat(ordered,",")~=raw then return nil end
end
local first=discovered_id and not seen[discovered_id] or false
if first then
seen[discovered_id]=true; ordered[#ordered+1]=discovered_id; table.sort(ordered)
local encoded=table.concat(ordered,",")
local write_ok=pcall(meta.set_string,meta,key,encoded)
local verify_ok,verified=pcall(meta.get_string,meta,key)
if not write_ok or not verify_ok or verified~=encoded then return nil end
end
return seen,first
end
function hearth.inspect_fauna(player,id,snapshot)
local ok,name=pcall(function() return player:get_player_name() end)
if not ok or type(name)~="string" or #name<1 or #name>64 or not hearth.sessions[name]
or type(C.get_player_by_name)~="function" then return false end
local online_ok,online=pcall(C.get_player_by_name,name)
if not online_ok or online~=player then return false end
local F=hearth.fauna
if type(id)~="string" or #id>80 or not F or type(F.get)~="function" then return false end
local loaded,record=pcall(F.get,id)
if not loaded or type(record)~="table" or record.id~=id then return false end
local view
if snapshot~=nil then
if type(snapshot)~="table" or getmetatable(snapshot)~=nil then return false end
view={}
for _,key in ipairs({"born_at","origin","foodreserve","resident","observation"}) do
local value=snapshot[key]
if key=="born_at" or key=="foodreserve" then
if type(value)~="number" or value~=value or math.abs(value)==math.huge then return false end
elseif value~=nil and type(value)~="string" and type(value)~="boolean" then return false end
view[key]=value
end
if view.born_at<0 or view.born_at%1~=0 or view.foodreserve<0 or view.foodreserve>1
or type(view.resident)~="boolean"
or (view.origin~="established" and view.origin~="born")
or (view.observation~="rest" and view.observation~="awake") then return false end
end
local sightings,first_sighting=fauna_observations(player,view and id or nil)
local function safe(value,limit)
if type(value)~="string" then return "unknown" end
return value:gsub("[%c]"," "):sub(1,limit or 240)
end
local function safe_words(set)
local out={}
if type(set)=="table" then
for key,enabled in pairs(set) do
if enabled and type(key)=="string" and #out<32 then out[#out+1]=safe(key,40):gsub("_"," ") end
end
end
table.sort(out)
return #out>0 and table.concat(out,", ") or "not specified"
end
local profile=record.profile or {}
local lines={}
local now_ok,now=pcall(os.time)
local phase
if view and now_ok and type(F.phase)=="function" then
local phase_ok,result=pcall(F.phase,id,view.born_at,now)
if phase_ok and type(result)=="table" then phase=result end
end
if phase then
lines[#lines+1]=string.format("Lifecycle: %s / approx %.2f model years; %.1f real calendar days",safe(phase.phase,40),phase.age_days/365.2425,phase.age_days)
else
lines[#lines+1]="Lifecycle age: unavailable for this profile or no individual selected."
end
if view then
local reserve=math.max(0,math.min(1,view.foodreserve))
lines[#lines+1]=string.format("Coarse modeled food reserve (policy, not biology): %.0f%%",reserve*100)
lines[#lines+1]="Current animation observation: "..(view.observation=="rest" and "resting" or "awake / active")
lines[#lines+1]="Residency: "..(view.resident and "persistent registered resident" or "persistent mapblock resident; registry ID unavailable")
.." ("..safe(view.origin,24).." origin); individual position is not shown."
lines[#lines+1]=sightings and (first_sighting and "Field journal: first recorded encounter with this species."
or "Field journal: this species is already recorded.") or "Field journal: unavailable; encounter was not recorded."
else
lines[#lines+1]="Individual observation: none; this is a species profile, not a local census."
lines[#lines+1]="Field journal: browsing this catalogue entry does not count as a wildlife encounter."
end
lines[#lines+1]="Modeled diet: "..safe_words(record.foods)
lines[#lines+1]="Activity / sleep policy: "..safe(record.activity,32).." / "..safe(record.sleep and record.sleep.kind,40)
lines[#lines+1]="Winter rest policy: "..(record.winter_dormant and "coarse dormancy; resident remains persistent" or "no special winter dormancy modeled")
lines[#lines+1]="Modeled behavior: "..safe(profile.movement or "movement details unavailable",220)
lines[#lines+1]="Modeled prey: "..safe_words(record.prey)
if profile.reproduction_enabled==true then
lines[#lines+1]="Reproduction: enabled only within the coarse model; mature compatible pairs, season, food and safe habitat are still required."
else
local evidence=profile.evidence and profile.evidence.reproduction_context
lines[#lines+1]="Reproduction: disabled / incomplete species-specific lifecycle evidence. "
..safe(evidence and evidence.note or "No breeding policy is enabled for this profile.",220)
end
local status=hearth.wildlife
if type(status)=="table" and type(status.status)=="function" then
local status_ok,data=pcall(status.status)
if status_ok and type(data)=="table" and type(data.counts)=="table" then
local n=tonumber(data.counts[id]); if n and n==n and math.abs(n)<math.huge then
lines[#lines+1]="Active loaded animals of this species: "..math.max(0,math.floor(n))
end
end
end
local source=safe(type(record.source_url)=="string" and record.source_url or "source unavailable",300)
local lifecycle_source=safe(type(profile.source_url)=="string" and profile.source_url or "UNKNOWN",300)
C.show_formspec(name,"hearth:fauna_detail",journal(14,10)
..label(0.65,0.55,"eom.dev / FAUNA FIELD NOTES",ink.mauve)
..label(0.65,1.15,safe(record.common,80):upper().." / "..safe(record.latin or "identity unverified",100),ink.yellow)
..divider(0.65,1.65,12.7)
.."textarea[0.65,2.0;12.7,5.7;;;"..esc(table.concat(lines,"\n\n")).."]"
.."textarea[0.65,7.8;12.7,0.8;;;"..esc("Identity source: "..source.."\nLifecycle source: "..lifecycle_source).."]"
.."button[6.15,8.85;3.3,0.65;fauna_index;Back to fauna guide]"
.."button_exit[10.05,8.85;3.3,0.65;close;Close]" )
return true
end
function hearth.fauna_guide(player,query,page)
local name=player:get_player_name()
local session=hearth.sessions[name]; if not session then return end
query=tostring(query or ""):sub(1,48):gsub("[%c]"," ")
local F=hearth.fauna
local records=F and type(F.list)=="function" and F.list() or {}
local observations=fauna_observations(player)
local observed_count=0
if observations then for _ in pairs(observations) do observed_count=observed_count+1 end end
local all,all_ids,needle={},{},query:lower()
local function timing(p,field)
local r=p and p[field]
if type(r)~="table" then return "UNKNOWN" end
return tostring(r.min).."-"..tostring(r.max).." real days ["..tostring(r.confidence).."]"
end
for _,r in ipairs(records) do
local p=r.profile
local marker=observations and observations[r.id] and " / recorded encounter" or " / not yet encountered"
local row=r.id.." / "..r.common.." / "..(r.latin or "UNKNOWN species identity (legacy deer)")..marker
.."\nHabitat/movement medium: "..r.medium.."; activity: "..r.activity
.."; food roles: "..words(r.foods)
.."\nMaturity: "..timing(p,"maturity_days").."; longevity context: "..timing(p,"lifespan_days")
.."\nLifecycle evidence: "..(p and p.confidence or "UNKNOWN")
.."; automatic breeding "..(p and p.reproduction_enabled==true and "enabled only for sourced profiles" or "disabled (incomplete profile)")
.."; no lifespan-endpoint death."
.."\nIdentity source: "..r.source_url
.."\nLifecycle source: "..(p and p.source_url or "UNKNOWN")
if needle=="" or row:lower():find(needle,1,true) then all[#all+1]=row; all_ids[#all_ids+1]=r.id end
end
local pages_count=math.max(1,math.ceil(#all/5)); page=index(page,pages_count)
local lines,choices={},{}
session.fauna_ids,session.fauna_selected={},1
for i=(page-1)*5+1,math.min(#all,page*5) do
lines[#lines+1]=all[i]; session.fauna_ids[#session.fauna_ids+1]=all_ids[i]; choices[#choices+1]=esc(all_ids[i])
end
if #lines==0 then lines[1]=#records==0 and "Fauna catalogue unavailable in this runtime."
or "No animal matches that literal search." end
session.fauna_query,session.fauna_page=query,page
C.show_formspec(name,"hearth:fauna",journal(14,10)
..label(0.65,0.55,"eom.dev / FAUNA FIELD GUIDE",ink.mauve)
..label(0.65,1.15,(observations and (observed_count.." / "..#records.." species recorded in your field journal")
or "Field journal unavailable / source evidence is not complete biology"),ink.yellow)
..divider(0.65,1.65,12.7)
.."field[0.7,1.95;9.2,0.7;query;Species, scientific name, food or status;"..esc(query).."]field_close_on_enter[query;false]"
.."button[10.25,1.95;3.1,0.7;search;Search]"
..label(0.7,2.95,"Page "..page.." / "..pages_count.." / "..#all.." matches",ink.muted)
.."textarea[0.65,3.3;12.7,4.0;;;"..esc(table.concat(lines,"\n\n")).."]"
.."textarea[0.65,7.4;12.7,0.8;;;"..esc("Real calendar age includes unloaded time; no offline meals, births or event replay. Movement and feeding cadence are conservative game policies, not complete true-life behavior. No web requests or world changes.").."]"
..(#choices>0 and "dropdown[0.7,8.25;5.0,0.45;species_choice;"..table.concat(choices,",")..";1;true]"
..tooltip("species_choice","Choose one of the species shown on this page.")
.."button[6,8.2;3.3,0.55;species_details;Species details]" or "")
..(page>1 and "button[0.7,8.85;2.2,0.65;previous;Previous]" or "")
..(page<pages_count and "button[3.05,8.85;2.2,0.65;next;Next]" or "")
.."button[6,8.85;3.3,0.65;botany_index;Botanical guide]"
.."button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
end
function hearth.inventory(player)
player:set_inventory_formspec(journal(12.4,11.3)
.. label(0.6,0.45,"eom.dev / THE LAST HEARTH",ink.mauve)
@@ -307,9 +502,9 @@ function hearth.inventory(player)
.. label(4.8,2.2,"RESULT",ink.teal) .. label(6.5,1.95,"GATHER / BUILD / BELONG",ink.yellow)
.. "textarea[6.5,2.4;5.2,1.7;;;Collect flint and sticks first.\nA roof keeps rain off.\nA hearth makes shelter a home.]"
.. "button[7.1,4.35;3.8,0.6;field_guide;Open field notes]"
.. "tooltip[field_guide;Read survival notes, recipes and the ecosystem survey.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("field_guide","Read survival notes, recipes and the ecosystem survey.")
.. "button[7.1,3.65;3.8,0.6;character_creator;Character appearance]"
.. "tooltip[character_creator;Preview woodland clothing and features. Cosmetic only; Save / Apply to keep changes.]"
.. tooltip("character_creator","Preview woodland clothing and features. Cosmetic only; Save / Apply to keep changes.")
.. label(0.6,5.25,"SATCHEL / first row is your hotbar",ink.muted)
.. "list[current_player;main;0.6,5.7;10,4;]listring[current_player;main]listring[current_player;craft]")
end
@@ -322,8 +517,8 @@ function hearth.location_form(player, error_text)
.. "box[0.6,3.95;4.5,0.8;" .. ink.mantle .. "]box[5.55,3.95;4.5,0.8;" .. ink.mantle .. "]"
.. "field[0.65,4;4.4,0.7;latitude;Latitude (-90 to 90);]field_close_on_enter[latitude;false]"
.. "field[5.6,4;4.4,0.7;longitude;Longitude (-180 to 180);]field_close_on_enter[longitude;false]"
.. "tooltip[latitude;Approximate decimal degrees, at most 16 characters.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. "tooltip[longitude;Local world only. Shared servers use administrator-selected coordinates.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("latitude","Approximate decimal degrees, at most 16 characters.")
.. tooltip("longitude","Local world only. Shared servers use administrator-selected coordinates.")
.. "textarea[0.65,4.95;9.5,0.75;;;" .. esc(C.colorize(error_text and ink.danger or ink.muted,
tostring(error_text or "No location chosen: neutral equatorial preview until configured."):sub(1,180))) .. "]"
.. "button[0.65,6;4.4,0.7;save_location;Use this location]"
@@ -359,9 +554,27 @@ function hearth.ecosystem(player)
.. "\nAquatic niche: " .. (type(h.aquatic) == "string" and h.aquatic:sub(1,32) or "unavailable")
.. " Water depth: " .. (h.near_water ~= nil and optional(h.water) or "unavailable")
end
local fauna = w and string.format("Hares: %d Deer: %d Foxes: %d\nActive total: %d / %d globally Local cap: %d Per-player cap: %d",
number(w.hare),number(w.deer),number(w.fox),number(w.total),number(w.global_cap),number(w.local_cap),number(w.player_cap))
or "Wildlife observations are not available in this runtime."
local fauna = "Wildlife observations are not available in this runtime."
if w then
local counts,lines=w.counts,{}
-- Legacy optional API fallback; production uses every returned species.
counts=type(counts)=="table" and counts or {hare=w.hare,deer=w.deer,fox=w.fox}
local ids={}; for id in pairs(counts) do if type(id)=="string" then ids[#ids+1]=id end end
table.sort(ids)
for i=1,math.min(#ids,64) do
local id=ids[i]
local name=({hare="Hares",deer="Deer",fox="Foxes"})[id] or id:sub(1,64):gsub("_"," ")
lines[#lines+1]=name .. ": " .. number(counts[id])
end
fauna=table.concat(lines," / ") .. string.format("\nActive total: %d / %d globally Local cap: %d Per-player cap: %d",
number(w.total),number(w.global_cap),number(w.local_cap),number(w.player_cap))
local recruitment=w.recruitment
if type(recruitment)=="table" then
local reason=type(recruitment.last_reason)=="string" and recruitment.last_reason:sub(1,40):gsub("[%c]"," ") or "unavailable"
fauna=fauna..string.format("\nAdmissions: %d / %d attempts Latest outcome: %s",
number(recruitment.successes),number(recruitment.attempts),reason)
end
end
C.show_formspec(player:get_player_name(), "hearth:ecosystem", journal(14,10)
.. label(0.65,0.55,"eom.dev / LIVING ECOSYSTEMS",ink.mauve)
.. label(0.65,1.15,"A MOMENT IN THE MEADOW",ink.yellow) .. divider(0.65,1.65,12.7)
@@ -370,11 +583,12 @@ function hearth.ecosystem(player)
.. label(0.7,4.85,h and (h.loaded and "Loaded sample / natural-origin plant counts; ground and shade include terrain" or "Incomplete sample / unloaded cells are unknown, not empty") or "Sample unavailable",
h and h.loaded and ink.muted or ink.yellow)
.. label(0.7,5.15,"WILDLIFE / all currently active animals, not a local census",ink.teal)
.. "textarea[0.65,5.55;12.7,1.25;;;" .. esc(fauna) .. "]"
.. "textarea[0.65,7.15;12.7,1.05;;;" .. esc("Observation does not advance succession or spawn animals. No terrain is loaded.\nNatural succession pauses while unloaded; crops and planted saplings follow separate rules.") .. "]"
.. "textarea[0.65,5.55;12.7,1.55;;;" .. esc(fauna) .. "]"
.. "textarea[0.65,7.15;12.7,1.05;;;" .. esc("Observation does not advance succession or spawn animals. No terrain is loaded.\nCalendar age includes unloaded time; ecological work is bounded and loaded-only. No missed-event replay; unknown breeding is disabled.") .. "]"
.. "button[0.7,8.85;2.8,0.65;refresh;Observe again]"
.. "tooltip[refresh;Read one fresh sample without changing the world.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("refresh","Read one fresh sample without changing the world.")
.. "button[3.7,8.85;3.3,0.65;botany_index;Botanical guide]"
.. "button[7.15,8.85;2.6,0.65;fauna_index;Fauna guide]"
.. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
end
@@ -382,6 +596,27 @@ C.register_on_player_receive_fields(function(player, form, fields)
if form == "hearth:species" then
reader.handle(player,fields)
return true
elseif form == "hearth:fauna_detail" then
local session=hearth.sessions[player:get_player_name()]
if not session then return true end
if fields.fauna_index then hearth.fauna_guide(player,session.fauna_query or "",session.fauna_page or 1) end
return true
elseif form == "hearth:fauna" then
local session=hearth.sessions[player:get_player_name()]; if not session then return true end
if fields.field_notes then hearth.guide(player,6)
elseif fields.botany_index then hearth.botanical_guide(player,"",1)
elseif fields.species_details then
local value=fields.species_choice
local n=type(value)=="string" and #value<=2 and value:match("^%d+$") and tonumber(value)
if n and n>=1 and n<=#(session.fauna_ids or {}) then
session.fauna_selected=n
local id=(session.fauna_ids or {})[n]
if id then hearth.inspect_fauna(player,id,nil) end
end
elseif fields.search or fields.key_enter then hearth.fauna_guide(player,fields.query,1)
elseif fields.next then hearth.fauna_guide(player,session.fauna_query,(session.fauna_page or 1)+1)
elseif fields.previous then hearth.fauna_guide(player,session.fauna_query,(session.fauna_page or 1)-1) end
return true
elseif form == "hearth:botany" then
local session = hearth.sessions[player:get_player_name()]
if not session then return true end
@@ -391,7 +626,8 @@ C.register_on_player_receive_fields(function(player, form, fields)
if not n or n<1 or n>#(session.botany_ids or {}) then return true end
session.botany_selected = n
end
if fields.field_notes then hearth.guide(player,6)
if fields.fauna_index then hearth.fauna_guide(player,"",1)
elseif fields.field_notes then hearth.guide(player,6)
elseif fields.ecosystem_survey then hearth.ecosystem(player)
elseif fields.species_article then
local id = (session.botany_ids or {})[session.botany_selected or 1]
@@ -402,7 +638,8 @@ C.register_on_player_receive_fields(function(player, form, fields)
return true
elseif form == "hearth:ecosystem" then
if not hearth.sessions[player:get_player_name()] then return true end
if fields.field_notes then hearth.guide(player,6)
if fields.fauna_index then hearth.fauna_guide(player,"",1)
elseif fields.field_notes then hearth.guide(player,6)
elseif fields.botany_index then hearth.botanical_guide(player,"",1)
elseif fields.refresh then hearth.ecosystem(player) end
return true
@@ -420,6 +657,7 @@ C.register_on_player_receive_fields(function(player, form, fields)
if fields.recipe_index then hearth.recipes(player, "", 1); return true end
if fields.ecosystem_survey then hearth.ecosystem(player); return true end
if fields.botany_index then hearth.botanical_guide(player,"",1); return true end
if fields.fauna_index then hearth.fauna_guide(player,"",1); return true end
if fields.inspect_target then
local ok, message = inspect(player, "")
if not ok and hearth.notify then hearth.notify(player,message) end
@@ -463,6 +701,12 @@ C.register_chatcommand("botany", {params = "[search]", description = "Browse nam
hearth.botanical_guide(player,param or "",1)
return true
end})
C.register_chatcommand("fauna", {params="[search]",description="Observe animal catalogue sources and conservative lifecycle status",
func=function(name,param)
local player=C.get_player_by_name(name)
if not player or not hearth.sessions[name] then return false,"Join the world first." end
hearth.fauna_guide(player,param or "",1); return true
end})
C.register_chatcommand("inspect", {params = "[exact species ID or Latin name]",
description = "Read a targeted species' offline Wikipedia excerpt and current game effects",
func = function(name, param)
+387
View File
@@ -0,0 +1,387 @@
-- Pure calendar policy, Lua 5.1+. No engine, clock reads, registrations or writes.
-- Ranges describe populations or explicit policy fallbacks, never death deadlines.
local L = {}
local DAY, YEAR, MAX_CLOCK = 86400, 365.2425, 9007199254740991
local function finite(n) return type(n)=="number" and n==n and math.abs(n)<math.huge end
local function clock(n) return finite(n) and n>=0 and n<=MAX_CLOCK end
local function copy(t)
if type(t)~="table" then return t end
local out={}; for k,v in pairs(t) do out[k]=copy(v) end; return out
end
local function range(a,b,url,note,statistic)
return {min=a,max=b,source_url=url or false,confidence=url and "source_reported" or "policy_fallback",
note=note or "Unverified group policy; not a measured species range.",statistic=statistic or "policy_window"}
end
local function years(a,b,url,note,statistic) return range(a*YEAR,b*YEAR,url,note,statistic) end
local WT="https://www.woodlandtrust.org.uk/trees-woods-and-wildlife/"
local WILDLIFE="https://www.wildlifetrusts.org/wildlife-explorer/"
local WIKI="https://en.wikipedia.org/wiki/"
local ADW="https://animaldiversity.org/accounts/"
local RSPB="https://www.rspb.org.uk/birds-and-wildlife/"
local plants, animals = {}, {}
local baseline={
trees={range(14,365),years(1,5),years(10,60),years(60,500)},
shrubs={range(14,365),years(1,2),years(2,10),years(5,80)},
grasses={range(7,90),range(30,90),range(90,365),years(2,10)},
herbs={range(7,90),range(90,365),range(365,730),years(2,10)},
ferns_mosses={range(30,730),years(1,2),years(2,5),false},
fungi={false,false,false,false}, aquatic={range(7,90),range(90,365),range(365,730),false},
lichens={false,years(1,5),false,false},
}
local function plant_record(id,group,latin,pending)
local b=baseline[group]
plants[id]={id=id,kind="plant",group=group,latin=latin or false,pending=pending or false,
germination_days=copy(b[1]),juvenile_days=copy(b[2]),maturity_days=copy(b[3]),lifespan_days=copy(b[4]),
life_cycle=group=="fungi" and "not_applicable" or "perennial",
life_cycle_confidence="policy_fallback",source_url=false,confidence="policy_fallback",
evidence={},seasonal_policy="dormant_not_dead",age_origin="seed_or_propagule"}
end
-- Fixed snapshot of all 100 registered IDs; no catalogue factory is invoked here.
local groups={
trees="oak birch pine willow alder beech aspen rowan hazel spruce",
shrubs="bilberry blackberry raspberry elder blackthorn hawthorn dog_rose holly juniper guelder_rose",
grasses="sedge bulrush meadow_fescue cocksfoot timothy sweet_vernal_grass yorkshire_fog tufted_hairgrass common_reed soft_rush",
herbs="clover nettle heather bluebell yarrow foxglove meadowsweet wild_garlic cowslip wood_anemone",
ferns_mosses="bracken lady_fern male_fern harts_tongue polypody sphagnum haircap_moss moss fork_moss star_moss",
fungi="fly_agaric birch_bolete bay_bolete chanterelle wood_blewit shaggy_inkcap turkey_tail oyster_fungus honey_fungus birch_polypore",
aquatic="duckweed yellow_waterlily white_waterlily water_crowfoot water_mint water_forgetmenot brooklime water_starwort hornwort pondweed",
lichens="reindeer_lichen oakmoss old_mans_beard lichen dog_lichen lungwort_lichen sunburst_lichen hammered_shield cup_lichen pixie_cup",
}
for group,text in pairs(groups) do for id in text:gmatch("%S+") do plant_record(id,group) end end
-- The added twenty are registered by botany, not by this pure module.
local extra_plants={
{"dandelion","Taraxacum officinale"},{"daisy","Bellis perennis"},{"red_clover","Trifolium pratense"},
{"birdsfoot_trefoil","Lotus corniculatus"},{"ribwort_plantain","Plantago lanceolata"},
{"selfheal","Prunella vulgaris"},{"knapweed","Centaurea nigra"},{"oxeye_daisy","Leucanthemum vulgare"},
{"field_scabious","Knautia arvensis"},{"teasel","Dipsacus fullonum"},{"cow_parsley","Anthriscus sylvestris"},
{"garlic_mustard","Alliaria petiolata"},{"honeysuckle","Lonicera periclymenum","shrubs"},
{"ivy","Hedera helix","shrubs"},{"gorse","Ulex europaeus","shrubs"},{"broom","Cytisus scoparius","shrubs"},
{"crab_apple","Malus sylvestris","trees"},{"wild_cherry","Prunus avium","trees"},
{"field_maple","Acer campestre","trees"},{"holly_fern","Polystichum setiferum","ferns_mosses"},
}
for _,r in ipairs(extra_plants) do plant_record(r[1],r[3] or "herbs",r[2],false) end
local function evidence(p,field,url,note)
p.evidence[field]={source_url=url,note=note,reviewed="2026-11-03"}
p.source_url=p.source_url or url; p.confidence="partial_species_evidence"
end
local function field(p,name,value)
p[name]=value; evidence(p,name,value.source_url,value.note)
end
-- All ten trees have distinct profiles. Unsourced timing fields retain their
-- own policy_fallback label even when a different field has species evidence.
local tree_rows={
{"oak","Quercus robur",40,60,500,1000,"https://www.forestryengland.uk/article/english-oak"},
{"birch","Betula pendula",10,20,60,90,"https://treesforlife.org.uk/into-the-forest/trees-plants-animals/trees/birch/birch-facts/"},
{"pine","Pinus sylvestris",15,30,150,300,WIKI.."Pinus_sylvestris"},
{"willow","Salix alba",5,15,60,120,WIKI.."Salix_alba"},
{"alder","Alnus glutinosa",10,20,60,160,WIKI.."Alnus_glutinosa"},
{"beech","Fagus sylvatica",10,30,300,500,WIKI.."Fagus_sylvatica"},
{"aspen","Populus tremula",10,20,80,200,WIKI.."Populus_tremula"},
{"rowan","Sorbus aucuparia",10,10,80,150,WIKI.."Sorbus_aucuparia"},
{"hazel","Corylus avellana",3,10,60,80,WT.."british-trees/a-z-of-british-trees/hazel/"},
{"spruce","Picea abies",20,40,200,532,WIKI.."Picea_abies"},
}
for _,r in ipairs(tree_rows) do
local p=plants[r[1]]; p.latin=r[2]
p.maturity_days=years(r[3],r[4])
p.lifespan_days=years(r[5],r[6])
evidence(p,"natural_history",r[7],"Species reference; only separately attributed fields are verified numerical claims.")
end
field(plants.oak,"maturity_days",years(40,40,tree_rows[1][7],"Acorns usually begin around 40 years; approximate report, not a universal minimum.","approximate_onset"))
field(plants.oak,"lifespan_days",years(500,1000,WIKI.."Quercus_robur","Long-lived survivors often exceed 500 years; some exceed 1000. Not a mortality distribution.","longevity_envelope"))
field(plants.birch,"lifespan_days",years(60,90,tree_rows[2][7],"Typical birch longevity; page covers silver and downy birch and notes exceptional ages above 150.","typical_range"))
field(plants.pine,"lifespan_days",years(150,300,tree_rows[3][7],"Normal reported lifespan; exceptional trees exceed this range.","typical_range"))
field(plants.alder,"lifespan_days",years(60,160,tree_rows[5][7],"Composite envelope: Woodland Trust approximately 60; Wikipedia up to 160. Not typical 60-160 survival.","composite_envelope"))
evidence(plants.alder,"lifespan_context",WT.."british-trees/a-z-of-british-trees/alder/","Approximately 60 years; contrasts with upper longevity reports.")
field(plants.beech,"maturity_days",years(10,30,tree_rows[6][7],"Small seed quantities around 10 years, heavy crops at least 30; different reproductive milestones.","onset_to_heavy_crop"))
field(plants.beech,"lifespan_days",years(300,500,tree_rows[6][7],"Undisturbed lifespan around 300; exceptional individual above 500.","longevity_envelope"))
field(plants.aspen,"lifespan_days",years(80,200,tree_rows[7][7],"Upper endpoint 200 reported; lower endpoint 80 is unverified policy, not a sourced range.","policy_envelope"))
plants.aspen.lifespan_days.confidence="policy_fallback" -- source supports only its upper limit
field(plants.rowan,"maturity_days",years(10,10,tree_rows[8][7],"Reported maturity at about ten years.","approximate_onset"))
field(plants.rowan,"lifespan_days",years(80,150,tree_rows[8][7],"Often not older than 80 in warmer regions; occasional 150.","longevity_envelope"))
field(plants.hazel,"lifespan_days",years(60,80,tree_rows[9][7],"Uncoppiced upper limit 80 reported; lower endpoint 60 is policy. Coppice stools can persist centuries.","policy_envelope"))
plants.hazel.lifespan_days.confidence="policy_fallback"
field(plants.spruce,"lifespan_days",years(200,532,tree_rows[10][7],"532-year ring-dated individual; lower endpoint 200 is policy, not sourced. Never use clonal root ages as stem ages.","policy_envelope"))
plants.spruce.lifespan_days.confidence="policy_fallback"
for _,id in ipairs({"pine","spruce","holly","ivy","gorse","holly_fern","harts_tongue","polypody"}) do
plants[id].seasonal_policy="retain_foliage_policy"
end
for _,id in ipairs({"fungi","lichens","ferns_mosses"}) do
for _,p in pairs(plants) do if p.group==id then p.age_origin="propagule_or_colony" end end
end
local garlic=WILDLIFE.."wildflowers/garlic-mustard"
plants.garlic_mustard.life_cycle="biennial"; plants.garlic_mustard.life_cycle_confidence="source_reported"
evidence(plants.garlic_mustard,"life_cycle",garlic,"First-season leaves retained over winter; flowers in second spring.")
plants.garlic_mustard.maturity_days=range(365,730,false,"Second-season policy window; not an exact source-measured number of days.")
plants.garlic_mustard.lifespan_days=range(365,730,false,"Two-season policy; do not kill at the first frost.")
local foxglove=WILDLIFE.."wildflowers/foxglove"
plants.foxglove.life_cycle="biennial"; plants.foxglove.life_cycle_confidence="source_reported"
evidence(plants.foxglove,"life_cycle",foxglove,"Flower stems in second year; biennial representation, not a guaranteed death date.")
plants.foxglove.maturity_days=copy(plants.garlic_mustard.maturity_days)
-- Registered teasel remains explicitly unverified rather than inventing a citation.
plants.teasel.life_cycle="biennial"
local function animal_record(id,latin,path,diet,movement,life_a,life_b)
local url=WILDLIFE..path
local p={id=id,latin=latin,kind="animal",pending=false,
maturity_days=false,gestation_days=false,incubation_days=false,juvenile_days=false,lifespan_days=false,
breeding_months=false,birth_count=false,birth_count_unit="young_per_litter",reproduction="unknown",
diet=diet,movement=movement,source_url=url,confidence="partial_species_evidence",evidence={},
age_origin="birth_or_hatching",reproduction_enabled=false}
animals[id]=p
evidence(p,"diet",url,diet); evidence(p,"movement",url,movement)
if life_a then field(p,"lifespan_days",years(life_a,life_b,url,"Reported average lifespan, not maximum age or a death deadline.","reported_average")) end
return p
end
animal_record("rabbit","Oryctolagus cuniculus","mammals/rabbit","grass and other vegetation","hopping; underground warrens",3,3)
animal_record("red_squirrel","Sciurus vulgaris","mammals/red-squirrel","nuts and conifer seeds","tree climbing and branch leaping; no hibernation",6,6)
animal_record("bank_vole","Myodes glareolus","mammals/bank-vole","fruit, nuts and small insects","agile; shallow burrows; no hibernation",.5,1.5)
animal_record("wood_mouse","Apodemus sylvaticus","mammals/wood-mouse","berries and seeds","mostly nocturnal; climbing and food caching",1,1)
animal_record("hedgehog","Erinaceus europaeus","mammals/european-hedgehog","invertebrates including beetles and earthworms","nocturnal; winter hibernation",2,3)
animal_record("badger","Meles meles","mammals/european-badger","worms, fruit, plants, eggs and small animals","digging; family groups in setts",5,8)
animal_record("roe_deer","Capreolus capreolus","mammals/roe-deer","tree and shrub buds and leaves; herbs and grasses","solitary in summer; loose winter groups",7,7)
animal_record("stoat","Mustela erminea","mammals/stoat","small rodents and rabbits","bounding gait; active day and night",2,5)
animal_record("weasel","Mustela nivalis","mammals/weasel","voles, mice and small birds","low running gait; enters rodent burrows",2,2)
animal_record("otter","Lutra lutra","mammals/european-otter","fish, amphibians, waterbirds and crustaceans","swimming; sheltered holts",5,10)
local function bird(id,latin,diet,movement,life)
local p=animal_record(id,latin,"unused",diet,movement)
p.source_url=RSPB..id:gsub("_","-"); p.evidence={}
evidence(p,"diet",p.source_url,diet); evidence(p,"movement",p.source_url,movement)
p.reproduction="egg"; p.birth_count_unit="eggs_per_clutch"
if life then field(p,"lifespan_days",years(life,life,p.source_url,"Reported average, not maximum or mandatory death age.","reported_average")) end
end
bird("robin","Erithacus rubecula","worms, insects, other invertebrates, seeds and fruit","hopping; territorial; mostly UK resident")
bird("blackbird","Turdus merula","worms, insects, berries and fruit","ground hopping; some seasonal migration",3)
bird("blue_tit","Cyanistes caeruleus","insects, caterpillars, seeds and nuts","clinging and flight; winter feeding flocks",3)
bird("barn_owl","Tyto alba","mice, voles, shrews and small birds","low silent hunting flight, often dawn or dusk")
bird("mallard","Anas platyrhynchos","seeds, plants, insects and shellfish","swimming and flight; resident or migratory")
animal_record("common_frog","Rana temporaria","amphibians/common-frog","invertebrates and smaller amphibians","hopping and swimming; winter shelter",5,10)
animal_record("smooth_newt","Lissotriton vulgaris","amphibians/smooth-newt","land invertebrates; aquatic crustaceans, molluscs and tadpoles","aquatic breeding; nocturnal land activity; winter shelter",6,6)
animal_record("brown_trout","Salmo trutta","freshwater-fish/brown-trout","insect larvae, small fish and flying insects","river swimming; freshwater resident form",15,20)
animal_record("bumblebee","Bombus terrestris","invertebrates/bees-and-wasps/buff-tailed-bumblebee","floral nectar and pollen","flight; underground colony; some winter-active colonies")
local worm=animal_record("earthworm","Lumbricus terrestris","unused","dead leaf litter and mineral soil","vertical burrows; moist-surface movement")
worm.source_url=WIKI.."Lumbricus_terrestris"; worm.evidence={}
evidence(worm,"diet",worm.source_url,worm.diet); evidence(worm,"movement",worm.source_url,worm.movement)
evidence(worm,"lifespan_context",worm.source_url,"Natural lifespan unknown; six years in captivity is not a wild lifespan range.")
field(worm,"maturity_days",years(1,2,worm.source_url,"At least one year development; reproductive maturity in second year.","development_window"))
worm.reproduction="paired_hermaphrodite_cocoon"
animal_record("hare","Lepus europaeus","mammals/brown-hare","vegetation and young woody bark","bounding; surface forms, not rabbit warrens",2,4)
animal_record("fox","Vulpes vulpes","mammals/red-fox","small mammals, birds, amphibians, worms and fruit","mostly nocturnal; territorial scent marking",2,3)
-- Generic legacy deer is deliberately NOT silently reidentified as roe deer.
animals.deer={id="deer",kind="animal",latin=false,pending=false,confidence="unverified_identity",source_url=false,
evidence={},maturity_days=false,juvenile_days=false,gestation_days=false,incubation_days=false,
lifespan_days=false,breeding_months=false,birth_count=false,reproduction="unknown",reproduction_enabled=false}
local function count(id,a,b)
local p=animals[id]; field(p,"birth_count",range(a,b,p.source_url,"Source-reported litter or clutch count; not guaranteed survivors.","offspring_count"))
end
count("rabbit",3,7); count("bank_vole",3,5); count("wood_mouse",4,8); count("stoat",6,12)
count("weasel",3,6); count("roe_deer",2,3); count("hare",2,4)
count("robin",4,5); count("blackbird",3,4)
local function months(id,list,url,note)
local p=animals[id]; p.breeding_months=list; evidence(p,"breeding_months",url or p.source_url,note)
end
months("roe_deer",{7,8},nil,"Mating July-August; delayed implantation; births May-June, not immediate gestation completion.")
animals.roe_deer.reproduction="delayed_implantation"
animals.stoat.reproduction="delayed_implantation"
evidence(animals.stoat,"reproduction",animals.stoat.source_url,"Summer mating, implantation delayed until following spring; no unsupported numeric gestation.")
months("blackbird",{3,4,5,6,7,8},nil,"UK nesting usually March-August, not a universal physiological limit.")
months("blue_tit",{4,5,6},nil,"UK nesting April-June; normally one brood annually.")
local hare=animals.hare
field(hare,"maturity_days",range(236,365.2425,ADW.."Lepus_europaeus/","Sexual maturity reported from about eight months to one year; conservative runtime uses the upper endpoint.","reported_range"))
field(hare,"gestation_days",range(30,42,ADW.."Lepus_europaeus/","Reported gestation range; several litters can occur during the breeding season.","reported_range"))
field(hare,"juvenile_days",range(30,30,ADW.."Lepus_europaeus/","About one month to weaning/independence; leverets are precocial at birth.","approximate_duration"))
field(hare,"birth_count",range(1,8,ADW.."Lepus_europaeus/","Reported range is 1-8 leverets; runtime uses the conservative lower endpoint.","offspring_count"))
months("hare",{1,2,3,4,5,6,7},ADW.."Lepus_europaeus/","Midwinter (January/February) through midsummer in the cited northern-range account.")
hare.reproduction="live_birth"; hare.reproduction_enabled=true
hare.breeding_seasons={Winter=true,Spring=true,Summer=true}; hare.season_policy="sourced_midwinter_to_midsummer_coarse_seasons"
hare.postpartum_days=30; hare.care_confidence="source_reported"
local fox=animals.fox
field(fox,"maturity_days",range(304,304,ADW.."Vulpes_vulpes/","ADW reports maturity at about ten months (304-day entry); both sexes are reported similarly.","reported_average"))
field(fox,"gestation_days",range(51,53,ADW.."Vulpes_vulpes/","Typical gestation is 51-53 days; the account also notes a broader 49-56-day span.","typical_range"))
field(fox,"juvenile_days",range(56,70,ADW.."Vulpes_vulpes/","Nursing/weaning lasts about 56-70 days; leaving the den at 4-5 weeks is not independence.","weaning"))
field(fox,"birth_count",range(1,9,ADW.."Vulpes_vulpes/","ADW's summary table gives 1-9 pups, while its prose gives 1-13; use the narrower table range and lower endpoint.","source_disagreement_envelope"))
months("fox",{12,1,2},ADW.."Vulpes_vulpes/","Central-range mating is reported in January-February; winter is the conservative coarse runtime window.")
fox.reproduction="live_birth"; fox.reproduction_enabled=true
fox.breeding_seasons={Winter=true}; fox.season_policy="central_range_winter_policy"
fox.postpartum_days=300; fox.care_confidence="policy_fallback"
-- Winter dormancy is a coarse loaded-world behavior, distinct from death or
-- a measured metabolic rate. These taxa retain their saved residents.
animals.hedgehog.winter_dormant=true
animals.common_frog.winter_dormant=true
animals.smooth_newt.winter_dormant=true
local rabbit=animals.rabbit
field(rabbit,"gestation_days",range(30,37,ADW.."Oryctolagus_cuniculus/","ADW reproduction reports 30-37 days; approximately 30 typical.","reported_range"))
field(rabbit,"juvenile_days",range(22,31,ADW.."Oryctolagus_cuniculus/","Weaning age, not sexual maturity.","weaning"))
-- ADW rabbit maturity prose (8 months) conflicts with an embedded 730-day
-- value. Keep maturity unknown rather than laundering that discrepancy.
evidence(rabbit,"maturity_context",ADW.."Oryctolagus_cuniculus/","Conflicting maturity values on this page: not accepted as a verified threshold.")
months("rabbit",{1,2,3,4,5,6,7,8,9,10,11,12},ADW.."Oryctolagus_cuniculus/","Can breed year-round, mostly first half of year; habitat/health still required.")
local squirrel=animals.red_squirrel
field(squirrel,"gestation_days",range(38,39,ADW.."Sciurus_vulgaris/","Gestation 38-39 days.","reported_range"))
field(squirrel,"juvenile_days",range(56,70,ADW.."Sciurus_vulgaris/","Independence eight to ten weeks, not sexual maturity.","independence"))
field(squirrel,"maturity_days",range(296,365.2425,ADW.."Sciurus_vulgaris/","Composite envelope of female 296-day entry, male 320-day entry and approximately one-year narrative.","composite_envelope"))
-- Source gives birth/litter months, not conception months: no month override.
local vole=animals.bank_vole
field(vole,"maturity_days",range(42,56,WT.."animals/mammals/bank-vole/","Females six weeks, males eight weeks; use sex-specific values if available.","sex_envelope"))
field(vole,"juvenile_days",range(25,25,WT.."animals/mammals/bank-vole/","Weaning about 25 days.","weaning"))
local mouse=animals.wood_mouse
field(mouse,"gestation_days",range(21,26,ADW.."Apodemus_sylvaticus/","Gestation range 21-26 days.","reported_range"))
field(mouse,"juvenile_days",range(21,21,ADW.."Apodemus_sylvaticus/","Weaning at three weeks.","weaning"))
field(mouse,"maturity_days",range(65,71,ADW.."Apodemus_sylvaticus/","Male 65-day and female 71-day entries; narrative approximately two months.","sex_envelope"))
field(animals.common_frog,"larval_days",range(98,98,animals.common_frog.source_url,"About 14 weeks AFTER hatching to froglet; not maturity or egg incubation.","metamorphosis"))
animals.common_frog.reproduction="external_eggs"; animals.smooth_newt.reproduction="eggs"
animals.brown_trout.reproduction="external_eggs"; animals.bumblebee.reproduction="colony_caste_dependent"
-- Additional retrieved reproductive accounts, reviewed 2026-11-03. Registration
-- status is independent of evidence. A lifespan is NOT a breeding prerequisite.
field(vole,"gestation_days",range(17,24,ADW.."Myodes_glareolus/","17-24 days, mean 21; upper endpoint includes lactation delay.","reported_range"))
months("bank_vole",{4,5,6,7,8,9},ADW.."Myodes_glareolus/","Late April-September; runtime uses conservative Spring/Summer season policy.")
months("wood_mouse",{2,3,4,5,6,7,8,9,10},WIKI.."Wood_mouse","February-October in this account; ADW instead says March-early winter.")
local blackbird=animals.blackbird
field(blackbird,"maturity_days",years(1,1,ADW.."Turdus_merula/","Usually starts mating at one year.","approximate_onset"))
field(blackbird,"incubation_days",range(13,14,ADW.."Turdus_merula/","13-day prose and 14-day summary; conservative composite.","composite_envelope"))
field(blackbird,"juvenile_days",range(13,14,ADW.."Turdus_merula/","Leaving nest at 13-14 days; NOT full independence.","fledging"))
local mallard=animals.mallard
field(mallard,"maturity_days",range(365,365,ADW.."Anas_platyrhynchos/","Both sexes reported at 365 days; yearling breeding.","reported_average"))
field(mallard,"incubation_days",range(26,28,ADW.."Anas_platyrhynchos/","Time to hatching 26-28 days.","reported_range"))
field(mallard,"birth_count",range(9,13,ADW.."Anas_platyrhynchos/","Typical hen lays 9-13 eggs; not guaranteed survivors.","clutch_count"))
field(mallard,"juvenile_days",range(50,60,WIKI.."Mallard","50-60 days to flight; not reproductive maturity.","fledging"))
local blue=animals.blue_tit
local diary="https://www.bto.org/understanding-birds/articles/blue-tit-diary"
field(blue,"incubation_days",range(14,14,diary,"About two weeks; approximate, not an exact physiological constant.","approximate_duration"))
field(blue,"birth_count",range(8,10,diary,"Majority of clutches contain 8-10 eggs; larger clutches exist.","typical_clutch"))
field(blue,"juvenile_days",range(18,21,diary,"Fledging 18-21 days; parents feed for further weeks.","fledging"))
evidence(blue,"maturity_context",diary,"Usually breeds in spring after hatching; no exact day threshold accepted. Caterpillar provisioning absent, so disabled.")
field(animals.common_frog,"maturity_days",years(3,3,WIKI.."Common_frog","Sexual maturity after three years in this account; local variation exists.","approximate_onset"))
field(animals.common_frog,"incubation_days",range(14,21,WIKI.."Common_frog","Eggs hatch after 2-3 weeks; temperature-dependent, NOT froglet age.","reported_range"))
evidence(animals.brown_trout,"reproduction_context",WIKI.."Brown_trout","About 2000 eggs/kg; requires female mass, gravel redd and temperature-dependent development, not a fixed litter.")
evidence(animals.bumblebee,"reproduction_context",WIKI.."Bombus_terrestris","Queen-founded colonies, diploid females/haploid males, larvae and pupae; caste-specific care cannot use a mammalian litter timer.")
-- Explicit seasonal/care policies, NOT source-reported month precision or a
-- claim to model nests/physiology. Upper dependency endpoints avoid postpartum
-- immediate breeding; bird care lock is deliberately longer than fledging.
for _,id in ipairs({"bank_vole","wood_mouse","blackbird","mallard"}) do
local p=animals[id]
p.reproduction_enabled=true
p.reproduction=(id=="bank_vole" or id=="wood_mouse") and "live_birth" or "egg"
p.breeding_seasons={Spring=true,Summer=true}
p.season_policy="conservative_temperate_spring_summer"
p.postpartum_days=math.max(21,p.juvenile_days.max)+(p.reproduction=="egg" and 21 or 0)
p.care_confidence="policy_fallback"
end
local function id_ok(id) return type(id)=="string" and #id<=64 and id:match("^[a-z][a-z_]*$")~=nil end
function L.plant(id,group)
if not id_ok(id) then return nil,"id" end
if plants[id] then
if group~=nil and group~=plants[id].group then return nil,"group" end
return copy(plants[id])
end
return nil,"unknown_species" -- never fabricate an identity from a group hint
end
function L.animal(id)
if not id_ok(id) then return nil,"id" end
if not animals[id] then return nil,"unknown_species" end
return copy(animals[id])
end
function L.ids(kind)
local out={}; local src=kind=="plant" and plants or kind=="animal" and animals
if not src then return nil,"kind" end
for id in pairs(src) do out[#out+1]=id end; table.sort(out); return out
end
local function valid_range(r)
return type(r)=="table" and finite(r.min) and finite(r.max) and r.min>=0 and r.max>=r.min and r.max<=1000000
end
-- Stable numeric seed, field salt <=64 bytes, bounded floating-point arithmetic.
-- Reproducible selection only; NOT cryptography and never math.randomseed().
function L.duration(r,seed,salt)
if not valid_range(r) then return nil,"range" end
if not finite(seed) or seed<0 or seed>2147483646 or seed%1~=0 then return nil,"seed" end
if type(salt)~="string" or #salt>64 then return nil,"salt" end
local h=seed+1.0
for i=1,#salt do h=(h*131.0+salt:byte(i))%2147483647.0 end
h=(h*48271.0)%2147483647.0
return r.min+(r.max-r.min)*(h/2147483647.0)
end
local durations={"germination_days","juvenile_days","maturity_days","lifespan_days","gestation_days","incubation_days","larval_days"}
function L.resolve(profile,seed)
if type(profile)~="table" or not id_ok(profile.id) then return nil,"profile" end
if not finite(seed) or seed<0 or seed>2147483646 or seed%1~=0 then return nil,"seed" end
local out=copy(profile); out.selected_days={}
for _,name in ipairs(durations) do
if profile[name] then
local n,err=L.duration(profile[name],seed,profile.id..":"..name)
if not n then return nil,err end
out.selected_days[name]=n
end
end
-- Overlapping stage ranges must not produce maturity before dependency or
-- germination. Clamp to the preceding selected endpoint, never outside a range.
local previous=0
for _,name in ipairs({"germination_days","juvenile_days","maturity_days","lifespan_days"}) do
local n=out.selected_days[name]
if n then
n=math.max(previous,n)
if n>profile[name].max then return nil,"profile_order" end
out.selected_days[name]=n; previous=n
end
end
out.selection_seed=seed; return out
end
function L.elapsed(previous,now,work_cap_seconds)
if not clock(previous) or not clock(now) then return nil,"clock" end
if now<previous then return nil,"clock_reversed" end
if not clock(work_cap_seconds) then return nil,"cap" end
local age_delta=now-previous; local work=math.min(age_delta,work_cap_seconds)
return {elapsed_seconds=age_delta,work_seconds=work,deferred_seconds=age_delta-work}
end
local function threshold(profile,name)
local r=profile[name]
if r==false or r==nil then return false end
if not valid_range(r) then return nil end
if profile.selected_days~=nil and type(profile.selected_days)~="table" then return nil end
local selected=profile.selected_days and profile.selected_days[name]
if selected~=nil then
if not finite(selected) or selected<r.min or selected>r.max then return nil end
return selected
end
return r.max -- conservative unsampled policy, not a random draw on each read
end
function L.phase(profile,born_at,now)
if type(profile)~="table" or (profile.kind~="plant" and profile.kind~="animal") then return nil,"profile" end
if not clock(born_at) or not clock(now) then return nil,"clock" end
if now<born_at then return nil,"clock_reversed" end
local age=(now-born_at)/DAY
local g,j,m,life=threshold(profile,"germination_days"),threshold(profile,"juvenile_days"),
threshold(profile,"maturity_days"),threshold(profile,"lifespan_days")
if g==nil or j==nil or m==nil or life==nil then return nil,"profile" end
if (g and j and g>j) or (j and m and j>m) or (m and life and m>life) then return nil,"profile_order" end
local phase="unknown"
if profile.kind=="plant" and g and age<g then phase="seed"
elseif j and age<j then phase=profile.kind=="plant" and "seedling" or "dependent"
elseif m and age<m then phase="juvenile"
elseif m then phase="mature" end
local beyond=life and age>=life or false
return {phase=phase,age_days=age,age_seconds=now-born_at,beyond_lifespan_range=beyond,
-- A population average or record age must NEVER be an automatic death.
death_due=false,reproductive_ready=false,confidence=profile.confidence}
end
function L.seasonal(profile,season)
if type(profile)~="table" or profile.kind~="plant" then return nil,"profile" end
if not ({Spring=true,Summer=true,Autumn=true,Winter=true})[season] then return nil,"season" end
return {dormant=season=="Winter" and profile.seasonal_policy=="dormant_not_dead",death_due=false,
confidence="policy_fallback"} -- presentation only; not a frost or survival model
end
function L.breeding_window(profile,month)
if type(profile)~="table" or profile.kind~="animal" then return nil,"profile" end
if not finite(month) or month%1~=0 or month<1 or month>12 then return nil,"month" end
local list=profile.breeding_months
if list==false or list==nil then return {known=false,within_window=false,eligible=false} end
if type(list)~="table" or #list>12 then return nil,"profile" end
local allowed=false
for _,m in ipairs(list) do
if not finite(m) or m%1~=0 or m<1 or m>12 then return nil,"profile" end
if m==month then allowed=true end
end
return {known=true,within_window=allowed,eligible=false} -- window alone never authorizes reproduction
end
L.VERSION=1; L.SECONDS_PER_DAY=DAY; L.DAYS_PER_YEAR=YEAR; L.MAX_CLOCK=MAX_CLOCK
return L
+2 -1
View File
@@ -1,5 +1,6 @@
name = hearth
title = The Last Hearth
description = Original survival-first eom.dev prototype. No external game dependencies.
description = Survival-first woodland game with original art and a pinned Creatura wildlife AI API.
author = eom.dev
min_minetest_version = 5.18
depends = creatura
+11 -6
View File
@@ -32,7 +32,7 @@ return function(hearth, api)
local function record(value)
return B.get(type(value)=="table" and value.id or value)
end
-- Identity references cover all 80 records. An identity link is not evidence
-- Identity references cover all 100 records. An identity link is not evidence
-- that every tagged trait has been reviewed or implemented.
function E.source(value)
local r=record(value); if not r then return nil end
@@ -53,10 +53,10 @@ return function(hearth, api)
local out={}; for _,r in ipairs(B.list()) do out[r.id]=E.source(r) end; return out
end
local tags={"nitrogenfixer","browse","pollinator","ectomycorrhiza","saprotroph","parasite","engineer","nursery","allelopathy","resin"}
local nitrogen="After 900 warm active seconds: at most 1 nutrient per eligible evaluation, 2 total per support via succ_fixed_nitrogen shared across species/restarts; stops at fertility 4 (soil cap 8)."
local nitrogen="After 90 real calendar days of root age, in warm active conditions: at most 1 nutrient per eligible evaluation, 2 total per support via succ_fixed_nitrogen shared across species/restarts; stops at fertility 4 (soil cap 8). Timing is game policy, not a sourced fixation rate."
local descriptions={
browse="Active natural plants count as wildlife forage; grazing consumes eligible plants, not mature trees. Not human food permission.",
pollinator="Only natural plants in their catalogue flowering season add to pollination. This input does not itself change berry timers.",
browse="Active natural plants count as wildlife forage; grazing debits a shared partial-canopy ration with a seven-real-day rest, not the perennial root. Not human food permission.",
pollinator="Mature natural plants in their flowering season support pollination. Nectar visits reserve a shared one-hour nectar deadline and at most one pollination credit expiring after one real day. Human harvesting spends that credit for at most one extra berry from an existing fruit crop; it never resets annual fruit readiness or creates seeds.",
nursery="Each active natural non-component plant adds nursery cover; any cover reduces succession stress accumulation to 75%. No extra animal spawn is promised.",
parasite="Not simulated: no infection or attack on living trees or player plants.",
allelopathy="Not simulated: no chemical suppression or plant killing.",
@@ -75,10 +75,10 @@ return function(hearth, api)
elseif tag=="browse" or tag=="pollinator" or tag=="nursery" then status="simulated"
elseif tag=="ectomycorrhiza" then
if r.group=="fungi" then
status,game="simulated","Active compatible nearby fungus permits seedling growth at 16200 instead of 21600 active seconds; successful growth debits at most 1 existing soil nutrient once, regardless of fungus count. No nutrient creation or carbon/water ledger."
status,game="simulated","Active compatible nearby fungus permits a finite nutrient exchange: successful calendar-eligible seedling growth debits at most 1 existing soil nutrient once, regardless of fungus count. No accelerated growth, nutrient creation or carbon/water ledger; lifecycle age thresholds are unchanged."
else game="Not simulated as a tree aura: only an active compatible nearby fungus can enable the finite seedling nutrient debit." end
elseif tag=="saprotroph" then
status,game="simulated","On suitable natural deadwood/litter only: advance its finite decay age by at most 60 seconds per evaluation, capped at 7200/1800. Only substrate decay pays soil nutrients once; the fungus pays none. Soil-growing saprotrophs receive no special nutrient effect."
status,game="simulated","On suitable natural deadwood/litter only: add at most 60 seconds per evaluation to its finite decay bonus, capped at one mean real year for deadwood or 90 real days for litter. Only substrate decay pays its remaining soil nutrients once; the fungus pays none. Soil-growing saprotrophs receive no special nutrient effect."
elseif tag=="engineer" then
if r.id=="sphagnum" then
status,game="simulated","Local land habitat estimate: +0.10 moisture and -0.20 pH per mat, at most 2 mats; moisture clamped 0..1, pH 3.5..8.5. No terrain, water or persistent pH writes."
@@ -91,6 +91,11 @@ return function(hearth, api)
if r.group=="trees" then
add("canopy","simulated","Trunks/crowns reduce local habitat light and contribute moisture through existing canopy sampling; pine/spruce retain full Winter influence, deciduous foliage retains 15%. Not a hydrological solver.")
end
local profile=hearth.lifecycle and hearth.lifecycle.plant(r.id)
add("lifecycle","simulated", "Calendar ages use real elapsed days, including unloaded time; reconciliation is bounded and loaded-only, with no missed-event replay. "
.. (profile and "Profile: "..profile.confidence..". Each timing field is separately source_reported, policy_fallback or UNKNOWN; an identity URL is not timing evidence."
or "Lifecycle profile UNKNOWN in this runtime; no invented maturity or lifespan.")
.. " Lifespan endpoints never authorize automatic death; fungal/lichen unknown maturity is not propagation permission.")
if r.toxic then add("toxicity","descriptive","Not simulated as an attack: this warning never grants a poison aura or human food permission.") end
return out
end
+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
File diff suppressed because one or more lines are too long
+924 -223
View File
File diff suppressed because it is too large Load Diff