Files
last-hearth/mods/hearth/botany.lua
T
2026-11-03 12:41:09 -05:00

360 lines
22 KiB
Lua
Executable File

-- Temperate NW-European catalogue and read-only niche policy. No scheduler/map writes.
-- Registration uses the content registrar's BARE-name convention.
return function(hearth, api)
local B = {groups={"trees","shrubs","grasses","herbs","ferns_mosses","fungi","aquatic","lichens"}}
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(text)
local r={}; for s in text:gmatch("[^,]+") do r[s]=true end; return r
end
local records, index, seasonal_nodes = {}, {}, {}
local soil="grass,soil,forest_floor,alluvium"
local acid="grass,soil,forest_floor,podzol,peat,alpine_turf"
local wet="alluvium,peat,soil,grass"
local rock="stone,granite,basalt,limestone,slate,sandstone,scree"
local year="Spring,Summer,Autumn,Winter"
local grow="Spring,Summer,Autumn"
local profiles={
wood={soil,4,15,.3,.85,4.5,7.5},
acid={acid,8,15,.2,.7,3.5,6.5},
wetwood={wet,6,15,.65,1,4,7.5},
shrub={soil,6,15,.3,.8,4.5,7.5},
meadow={"grass,soil,alluvium",9,15,.3,.8,5,7.5},
bank={wet,7,15,.75,1,4.5,8},
shade={"forest_floor,soil,alluvium",3,11,.45,.9,4.5,7.5},
moss={acid,2,10,.6,1,3.5,7},
litter={"forest_floor,litter",2,12,.45,.85,4,7.5},
deadwood={"deadwood",2,13,.4,.95,3.5,7.5},
water={"alluvium,soil,peat,sand",7,15,.8,1,5,8.5},
bark={"bark",5,13,.4,.9,4,7},
lichenrock={rock,9,15,.1,.6,3.5,7.5},
}
-- Each row owns exactly one group. Interactions describe ecology, not edibility.
local group
local function add(id,common,latin,profile,seasons,tags,extra)
local p=assert(profiles[profile])
local r={id=id,common=common,latin=latin,group=group,seasons=set(seasons),tags=set(tags),
niche={substrates=set(p[1]),light={p[2],p[3]},moisture={p[4],p[5]},ph={p[6],p[7]},
aquatic="land",water={0,0}},toxic=false,food=false,existing=false}
for k,v in pairs(extra or {}) do r[k]=v end
r.node="hearth:"..id..(group=="trees" and "_seedling" or "")
r.texture="hearth_botany_"..id..".png"
records[#records+1]=r; assert(not index[id],id); index[id]=r
end
group="trees"
add("oak","Pedunculate oak","Quercus robur","wood",grow,"browse,ectomycorrhiza,nursery",{existing=true})
add("birch","Silver birch","Betula pendula","acid",grow,"browse,ectomycorrhiza,nursery",{existing=true})
add("pine","Scots pine","Pinus sylvestris","acid",year,"ectomycorrhiza,nursery,resin",{existing=true})
add("willow","White willow","Salix alba","wetwood",grow,"pollinator,browse,ectomycorrhiza,engineer",{existing=true})
add("alder","Common alder","Alnus glutinosa","wetwood",grow,"nitrogenfixer,ectomycorrhiza,engineer",{existing=true})
add("beech","European beech","Fagus sylvatica","wood",grow,"browse,ectomycorrhiza,nursery")
add("aspen","European aspen","Populus tremula","wood",grow,"browse,ectomycorrhiza,nursery")
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")
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})
add("raspberry","Red raspberry","Rubus idaeus","shrub",grow,"pollinator,browse,nursery",{food=true})
add("elder","Black elder","Sambucus nigra","shrub",grow,"pollinator,browse,nursery",{toxic=true})
add("blackthorn","Blackthorn","Prunus spinosa","shrub",grow,"pollinator,browse,nursery")
add("hawthorn","Common hawthorn","Crataegus monogyna","shrub",grow,"pollinator,browse,nursery")
add("dog_rose","Dog rose","Rosa canina","shrub",grow,"pollinator,browse,nursery")
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})
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})
add("meadow_fescue","Meadow fescue","Schedonorus pratensis","meadow",grow,"browse,engineer")
add("cocksfoot","Cock's-foot","Dactylis glomerata","meadow",grow,"browse,engineer")
add("timothy","Timothy","Phleum pratense","meadow",grow,"browse,engineer")
add("sweet_vernal_grass","Sweet vernal-grass","Anthoxanthum odoratum","acid",grow,"browse,engineer")
add("yorkshire_fog","Yorkshire-fog","Holcus lanatus","meadow",grow,"browse,engineer")
add("tufted_hairgrass","Tufted hair-grass","Deschampsia cespitosa","bank",grow,"browse,engineer,nursery")
add("common_reed","Common reed","Phragmites australis","bank",grow,"engineer,nursery")
add("soft_rush","Soft rush","Juncus effusus","bank",grow,"engineer,nursery")
group="herbs"
add("clover","White clover","Trifolium repens","meadow",grow,"nitrogenfixer,pollinator,browse",{existing=true})
add("nettle","Common nettle","Urtica dioica","shade",grow,"browse,nursery",{existing=true,toxic=true})
add("heather","Heather","Calluna vulgaris","acid",grow,"pollinator,browse,nursery",{existing=true})
add("bluebell","English bluebell","Hyacinthoides non-scripta","shade","Spring","pollinator",{existing=true,toxic=true})
add("yarrow","Yarrow","Achillea millefolium","meadow",grow,"pollinator")
add("foxglove","Common foxglove","Digitalis purpurea","acid",grow,"pollinator",{toxic=true})
add("meadowsweet","Meadowsweet","Filipendula ulmaria","bank",grow,"pollinator,nursery")
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})
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")
add("male_fern","Male-fern","Dryopteris filix-mas","shade",grow,"nursery",{toxic=true})
add("harts_tongue","Hart's-tongue fern","Asplenium scolopendrium","shade",year,"nursery")
add("polypody","Common polypody","Polypodium vulgare","shade",year,"nursery")
add("sphagnum","Blunt-leaved bog-moss","Sphagnum palustre","moss",year,"engineer,nursery")
add("haircap_moss","Common haircap","Polytrichum commune","moss",year,"engineer,nursery")
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")
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")
add("bay_bolete","Bay bolete","Imleria badia","litter","Summer,Autumn","ectomycorrhiza")
add("chanterelle","Chanterelle","Cantharellus cibarius","litter","Summer,Autumn","ectomycorrhiza")
add("wood_blewit","Wood blewit","Collybia nuda","litter","Autumn,Winter","saprotroph")
add("shaggy_inkcap","Shaggy inkcap","Coprinus comatus","meadow","Summer,Autumn","saprotroph")
add("turkey_tail","Turkey tail","Trametes versicolor","deadwood",year,"saprotroph")
add("oyster_fungus","Oyster mushroom","Pleurotus ostreatus","deadwood","Autumn,Winter","saprotroph")
add("honey_fungus","Honey fungus","Armillaria mellea","deadwood","Autumn","saprotroph,parasite",{toxic=true})
add("birch_polypore","Birch polypore","Fomitopsis betulina","deadwood",year,"saprotroph,parasite")
group="aquatic"
add("duckweed","Common duckweed","Lemna minor","water",grow,"nursery,engineer")
add("yellow_waterlily","Yellow water-lily","Nuphar lutea","water",grow,"pollinator,nursery",{toxic=true})
add("white_waterlily","White water-lily","Nymphaea alba","water",grow,"pollinator,nursery")
add("water_crowfoot","Common water-crowfoot","Ranunculus aquatilis","water",grow,"pollinator,nursery",{toxic=true})
add("water_mint","Water mint","Mentha aquatica","bank",grow,"pollinator,nursery")
add("water_forgetmenot","Water forget-me-not","Myosotis scorpioides","bank",grow,"pollinator,nursery")
add("brooklime","Brooklime","Veronica beccabunga","bank",grow,"pollinator,nursery")
add("water_starwort","Common water-starwort","Callitriche stagnalis","water",grow,"nursery")
add("hornwort","Rigid hornwort","Ceratophyllum demersum","water",grow,"nursery,engineer")
add("pondweed","Broad-leaved pondweed","Potamogeton natans","water",grow,"nursery")
group="lichens"
add("reindeer_lichen","Reindeer lichen","Cladonia rangiferina","acid",year,"browse,nursery")
add("oakmoss","Oakmoss","Evernia prunastri","bark",year,"nursery")
add("old_mans_beard","Old man's beard","Usnea subfloridana","bark",year,"nursery")
add("lichen","Map lichen","Rhizocarpon geographicum","lichenrock",year,"engineer",{existing=true})
add("dog_lichen","Dog lichen","Peltigera canina","moss",year,"nitrogenfixer,engineer")
add("lungwort_lichen","Lungwort lichen","Lobaria pulmonaria","bark",year,"nitrogenfixer,nursery")
add("sunburst_lichen","Common orange lichen","Xanthoria parietina","bark",year,"engineer")
add("hammered_shield","Hammered shield lichen","Parmelia sulcata","bark",year,"nursery")
add("cup_lichen","Mealy pixie-cup","Cladonia chlorophaea","acid",year,"engineer")
add("pixie_cup","Pebbled pixie-cup","Cladonia pyxidata","acid",year,"engineer")
-- Species-level refinements; ranges are game niche windows, not field measurements.
local function niche(id,changes) for k,v in pairs(changes) do index[id].niche[k]=v end end
niche("beech",{moisture={.3,.7},ph={5,8}})
niche("rowan",{light={5,15},moisture={.3,.85}})
niche("spruce",{moisture={.4,.85},light={4,15}})
niche("hazel",{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}})
niche("harts_tongue",{ph={6,8}})
niche("polypody",{substrates=set("forest_floor,soil,bark,stone,granite,slate"),ph={4,7}})
niche("sphagnum",{substrates=set("peat,alluvium"),moisture={.85,1},ph={3.5,6}})
niche("haircap_moss",{light={4,15},ph={3.5,6}})
niche("moss",{substrates=set(acid..",bark,deadwood,"..rock)})
niche("dog_lichen",{light={5,13},moisture={.4,.85},ph={5,8}})
niche("lungwort_lichen",{light={3,10},moisture={.65,1},ph={5,7.5}})
niche("sunburst_lichen",{substrates=set("bark,limestone,stone"),light={9,15},moisture={.2,.75},ph={5.5,8.5}})
niche("lichen",{substrates=set("stone,granite,slate,sandstone,scree"),ph={3.5,6.5}})
local hosts={fly_agaric="birch,pine,spruce",birch_bolete="birch",bay_bolete="pine,spruce,beech,oak",
chanterelle="oak,beech,birch,pine,spruce",birch_polypore="birch",
turkey_tail="oak,birch,willow,alder,beech,aspen,rowan,hazel",
oyster_fungus="beech,willow,aspen,oak,birch",honey_fungus="oak,birch,willow,alder,beech,aspen,rowan,hazel,spruce"}
for id,text in pairs(hosts) do niche(id,{hosts=set(text)}) end
for _,id in ipairs({"fly_agaric","birch_bolete","bay_bolete","chanterelle"}) do
niche(id,{substrates=set("forest_floor,soil,podzol"),ph={3.5,6.5}})
end
for _,id in ipairs({"duckweed","yellow_waterlily","white_waterlily","water_crowfoot","pondweed"}) do
niche(id,{aquatic="surface",water={1,id=="duckweed" and 6 or 3}})
end
for _,id in ipairs({"water_starwort","hornwort"}) do niche(id,{aquatic="submerged",water={1,4}}) end
for _,id in ipairs({"water_mint","water_forgetmenot","brooklime","sedge","bulrush","common_reed","soft_rush","tufted_hairgrass"}) do
niche(id,{aquatic="bank",water={0,0}})
end
index.hornwort.niche.substrates=set("water") -- rootless: never requires a mud anchor
local flowers={willow="Spring",rowan="Spring,Summer",bilberry="Spring,Summer",blackberry="Summer",
raspberry="Summer",elder="Summer",blackthorn="Spring",hawthorn="Spring",dog_rose="Summer",
holly="Spring,Summer",guelder_rose="Spring,Summer",clover="Spring,Summer",heather="Summer,Autumn",
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"}
for id,text in pairs(flowers) do index[id].flowering=set(text) end
for _,id in ipairs({"bilberry","blackberry","raspberry"}) do
index[id].fruiting=set(id=="blackberry" and "Summer,Autumn" or "Summer")
end
local descriptors={
nitrogenfixer={description="Symbionts fix atmospheric nitrogen; bounded soil contribution, never an item source.",cap=2},
browse={description="Wildlife forage or cover relation; not human food permission."},
pollinator={description="Flower-season pollen/nectar resource; not a year-round aura."},
ectomycorrhiza={description="Host-specific root exchange of existing mineral/water resources for tree carbon.",cap=1},
saprotroph={description="Decomposes existing litter or deadwood; any transfer must consume that substrate."},
parasite={description="Natural-history descriptor only; never damages player plants.",enabled=false},
engineer={description="Roots or mats retain substrate/water, or aquatic cover changes light; bounded local effect."},
nursery={description="Shelter/microhabitat for wildlife or recruits, conditional on appropriate habitat."},
allelopathy={description="Bracken chemical competition is context-dependent; descriptor only, no killing.",enabled=false},
resin={description="Resin-bearing conifer; resource extraction belongs to tree/resource policy."},
}
local function finite(n) return type(n)=="number" and n==n and math.abs(n)<math.huge end
local function raw_kind(value)
local name=type(value)=="table" and value.name or value
if type(name)~="string" then return nil end
if seasonal_nodes[name] then return seasonal_nodes[name] end
if hearth.seasons and hearth.seasons.kind then name=hearth.seasons.kind(name) or name end
return name:gsub("^hearth:","")
end
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]+)$")
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
local function record(value) local id=B.kind(value); return id and index[id] end
function B.get(value) return copy(record(value)) end
function B.group_of(value) local r=record(value); return r and r.group end
function B.list(g)
local out={}; for _,r in ipairs(records) do if not g or r.group==g then out[#out+1]=copy(r) end end; return out
end
function B.has_tag(value,tag) local r=record(value); return r~=nil and r.tags[tag]==true end
function B.interactions(value)
local r=record(value); local out={}
if r then for tag in pairs(r.tags) do out[tag]=copy(descriptors[tag]) end end
return out
end
function B.active(value,season) local r=record(value); return r~=nil and r.seasons[season]==true end
-- Install policy at factory time, before content calls seasons.register().
-- Diversity owns the eight existing understory IDs and every tree stage.
local seasons=hearth.seasons
if seasons and seasons.add_variants then
for _,r in ipairs(records) do
if r.group~="trees" and not r.existing then
local definitions={}
for _,season in ipairs({"Spring","Summer","Autumn","Winter"}) do
if not B.active(r.id,season) then
definitions[season]={texture="hearth_botany_"..r.id.."_dormant.png"}
seasonal_nodes[r.node.."_"..season:lower()]=r.id
end
end
-- A copy prevents optional consumers from mutating catalogue policy.
seasons.add_variants(r.id,definitions,copy(r.seasons))
end
end
end
function B.pollinator(value,season)
local r=record(value); return r~=nil and r.tags.pollinator==true and r.flowering[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
function B.browse(value,season) return B.active(value,season) and B.has_tag(value,"browse") end
function B.resin(value) return B.has_tag(value,"resin") end
-- Pure effect suggestions; caller owns metadata, nutrient budgets and protection.
function B.nitrogen(value,current)
if not B.has_tag(value,"nitrogenfixer") or not finite(current) or current<0 then return 0 end
return math.min(1,math.max(0,2-current))
end
function B.root_fungi(value,tree,budget)
local r,t=record(value),B.kind(tree)
if not r or r.group~="fungi" or not r.tags.ectomycorrhiza or not r.niche.hosts[t]
or not finite(budget) or budget<=0 then return 0 end
return math.min(1,budget) -- transfer ceiling, not newly created nutrients
end
local function within(n,range) return finite(n) and n>=range[1] and n<=range[2] end
function B.suitable(value,h,season)
local r=record(value)
if not r or type(h)~="table" or h.loaded~=true or not B.active(value,season) then return false end
local n=r.niche
if not within(h.light,n.light) or not within(h.moisture,n.moisture) or not within(h.ph,n.ph) then return false end
if n.nutrients and (not finite(h.nutrients) or h.nutrients<n.nutrients) then return false end
if n.hosts and not n.hosts[B.kind(h.host)] then return false end
local depth=h.water or 0
if not within(depth,n.water) then return false end
local ground=raw_kind(h.substrate or h.ground)
if n.aquatic=="surface" or n.aquatic=="submerged" then
if h.aquatic~=n.aquatic or h.freshwater~=true then return false end
if r.id=="hornwort" then return ground=="water" end
return n.substrates[raw_kind(h.ground)]==true
end
if h.aquatic and h.aquatic~="land" and h.aquatic~="bank" then return false end
if n.aquatic=="bank" and h.aquatic~="bank" then return false end
return n.substrates[ground]==true
end
function B.choose(p,h,season)
if type(p)~="table" or type(h)~="table" then return nil end
for _,axis in ipairs({"x","y","z"}) do if not finite(p[axis]) or math.abs(p[axis])>30990 or p[axis]%1~=0 then return nil end end
local start=math.abs(p.x*17+p.y*7+p.z*31)%#records
for i=1,#records do
local r=records[(start+i-1)%#records+1]
if (r.group~="trees" or h.include_trees==true) and B.suitable(r.id,h,season) then
return r.node:sub(8),copy(r)
end
end
end
local DAY=86400
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
return math.floor(n)
end
function B.harvest(pos,player)
if type(pos)~="table" then return false,"invalid" end
for _,a in ipairs({"x","y","z"}) do if not finite(pos[a]) or math.abs(pos[a])>30990 or pos[a]%1~=0 then return false,"invalid" end end
local construction=hearth.construction -- late lookup: content constructs this later
if not player or not player.is_player or not player:is_player() or not construction
or not construction.allowed or not construction.allowed(pos,player,"use") then return false,"denied" end
local node=api.get_node_or_nil(pos)
local r=node and record(node)
-- Canonicalization alone is not authority: accept only the base or an
-- exact seasonal name installed above, never arbitrary suffix aliases.
if not r or not r.food or (node.name~=r.node and seasonal_nodes[node.name]~=r.id) then return false,"species" end
local time=now(); if not time then return false,"clock" end
local climate=hearth.climate_at and hearth.climate_at(time)
if not climate or not B.active(r.id,climate.season) or not B.edible(r.id,climate.season) then return false,"season" end
local meta=api.get_meta(pos)
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 ready>time then return false,"cooldown" end
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")
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
end
local registered=false
function B.register_nodes(register_node)
if registered then return end
assert(type(register_node)=="function","botany needs a bare-name node registrar")
for _,r in ipairs(records) do
if r.group~="trees" and not r.existing and not (api.registered_nodes or {})[r.node] then
local id=r.id
local def={description=r.common.." ("..r.latin..")",drawtype="plantlike",tiles={r.texture},
inventory_image=r.texture,wield_image=r.texture,paramtype="light",sunlight_propagates=true,
use_texture_alpha="clip",walkable=false,buildable_to=false,
groups={snappy=3,flora=1,hearth_ecosystem=1},drop="hearth:"..id,
after_place_node=function(pos) api.get_meta(pos):set_string("player_placed","1") end}
-- A dug bush cannot reset its fruit cooldown by becoming a fresh item.
if r.group=="shrubs" then def.drop="hearth:sticks" end
if r.niche.aquatic=="surface" then
def.drawtype="nodebox"; def.node_box={type="fixed",fixed={-.45,-.49,-.45,.45,-.44,.45}}
def.selection_box=copy(def.node_box)
elseif r.niche.aquatic=="submerged" then
-- A sprite is NOT water: never give it liquid groups/source behavior.
-- External placement must retain a separate real water column.
def.groups.hearth_submerged=1
end
if r.niche.aquatic=="surface" or r.niche.aquatic=="submerged" then
-- Default item_place would replace buildable-to water. Safe external
-- ecology placement must reserve a distinct air cell instead.
def.on_place=function(stack) return stack end
end
if r.food then def.on_rightclick=function(pos,_,player,stack) B.harvest(pos,player); return stack end end
register_node(id,def)
end
end
registered=true
end
B.catalogue=B.list(); B.group_order=copy(B.groups)
return B
end