Files
2026-11-05 09:57:11 -05:00

1003 lines
49 KiB
Lua
Executable File

-- Loaded ecology, persisted calendar ages. No emergence, items or player combat.
return function(hearth, api)
local modpath=api.get_modpath and api.get_modpath("hearth")
local F=hearth.fauna or dofile(assert(modpath,"wildlife needs fauna or get_modpath").."/fauna.lua")(hearth,api)
local GLOBAL,PLAYER,LOCAL,RADIUS,BUDGET=64,16,12,48,24
local DAY,MAX=86400,9007199254740991
local REGION,FOUNDERS,REGION_LIMIT,REGISTRY_BYTES=48,4,4096,1048576
local STORAGE_KEY="wildlife_residents_v1"
local records,order={},{}
for _,r in ipairs(F.list()) do records[r.id]=r; order[#order+1]=r.id end
local active,counts={},{}
for _,id in ipairs(order) do counts[id]=0 end
local total,serial,actions,births,meals,grazed,dung,deaths,pollinations,dispersals,detritus=0,0,0,0,0,0,0,0,0,0,0
local clock,credit,recruitment,cursor,recruit_kind,player_cursor=0,0,0,0,0,0
local candidate_cursor,patch_cursor=0,0
local recruiting={attempts=0,successes=0,scans=0,last_reason="waiting",reasons={}}
local directions={{1,0},{1,1},{0,1},{-1,1},{-1,0},{-1,-1},{0,-1},{1,-1}}
local offsets={{16,0},{0,16},{-16,0},{0,-16},{20,8},{-20,-8},{8,-20},{-8,20}}
local function finite(n) return type(n)=="number" and n==n and math.abs(n)<math.huge end
local function epoch(n) return finite(n) and n>=0 and n<=MAX and n%1==0 end
local function decimal(n) return string.format("%.0f",n) end
local function now()
local t=api.now and api.now() or os.time()
return epoch(t) and t<=MAX-366*DAY and t or nil
end
local function delta(t) return finite(t) and t>0 and math.min(t,1) or 0 end
local function valid(p)
if type(p)~="table" then return false end
for _,a in ipairs({"x","y","z"}) do if not finite(p[a]) or math.abs(p[a])>30900 then return false end end
return true
end
local function players()
return api.get_connected_players and api.get_connected_players() or {}
end
local function log(reason)
if api.log then api.log("warning","[hearth wildlife] "..reason) end
end
-- Flat, explicitly versioned JSON. Region values are a bounded canonical
-- journal: attempts/last-attempt/id=species;... . Never evict or truncate.
local storage=hearth.storage
if not storage and api.get_mod_storage then
local ok,s=pcall(api.get_mod_storage); if ok then storage=s end
end
local ledger={version=1,counter=0}; local regions,region_count={},0
local storage_ok=storage and type(storage.get_string)=="function" and type(storage.set_string)=="function" or false
local function fail_storage()
if storage_ok then log("resident registry unavailable or invalid; new admissions disabled") end
storage_ok=false; return false
end
local function region_key(p) return "r_"..math.floor(p.x/REGION).."_"..math.floor(p.z/REGION) end
local function valid_region(k)
if type(k)~="string" then return false end
local x,z=k:match("^r_(-?%d+)_(-?%d+)$")
x,z=tonumber(x),tonumber(z)
return x and z and math.abs(x)<=644 and math.abs(z)<=644
and k=="r_"..x.."_"..z
end
local function flat(text,limit)
return type(text)=="string" and #text>0 and #text<=limit
and text:sub(1,1)=="{" and text:sub(-1)=="}"
and not text:sub(2,-2):find("[{}%[%]]")
end
local function read_registry()
if not storage_ok or not api.parse_json or not api.write_json then return fail_storage() end
local ok,text=pcall(storage.get_string,storage,STORAGE_KEY)
if not ok or type(text)~="string" then return fail_storage() end
if text=="" then return true end
if not flat(text,REGISTRY_BYTES) then return fail_storage() end
local parsed; ok,parsed=pcall(api.parse_json,text,nil,true)
if not ok or type(parsed)~="table" or getmetatable(parsed) or parsed.version~=1
or not epoch(parsed.counter) then return fail_storage() end
local used={}
for k,v in pairs(parsed) do
if k~="version" and k~="counter" then
if not valid_region(k) or type(v)~="string" or #v>256 or region_count>=REGION_LIMIT then return fail_storage() end
local attempts,time,members=v:match("^(%d+)/(%d+)/(.*)$")
attempts,time=tonumber(attempts),tonumber(time)
if not epoch(attempts) or attempts>1000000000 or not epoch(time) then return fail_storage() end
local r={attempts=attempts,time=time,members={}}; local rebuilt=""
for n,id in members:gmatch("(%d+)=([%w_]+);") do
local individual=tonumber(n)
if not epoch(individual) or individual<1 or individual>parsed.counter or used[individual]
or not records[id] or #r.members>=FOUNDERS or decimal(individual)~=n then return fail_storage() end
used[individual]=true; r.members[#r.members+1]={id=individual,species=id}
rebuilt=rebuilt..n.."="..id..";"
end
if rebuilt~=members then return fail_storage() end
regions[k]=r; region_count=region_count+1
end
end
ledger=parsed; return true
end
local function persist()
if not storage_ok then return false end
local ok,text=pcall(api.write_json,ledger)
if not ok or not flat(text,REGISTRY_BYTES) then return fail_storage() end
local written; ok,written=pcall(storage.set_string,storage,STORAGE_KEY,text)
if not ok or written==false then return fail_storage() end
local verified; ok,verified=pcall(storage.get_string,storage,STORAGE_KEY)
if not ok or verified~=text then return fail_storage() end
return true
end
local function journal(k,r)
local v=decimal(r.attempts).."/"..decimal(r.time).."/"
for _,m in ipairs(r.members) do v=v..decimal(m.id).."="..m.species..";" end
ledger[k]=v
end
local function region(k,time,attempt)
if not storage_ok then return nil end
local r=regions[k]
if not r then
if region_count>=REGION_LIMIT then return nil end
r={attempts=0,time=time,members={}}; regions[k]=r; region_count=region_count+1
end
if attempt then r.attempts=math.min(1000000000,r.attempts+1); r.time=time end
journal(k,r); return persist() and r or nil
end
local function identity(id,p,time,founder)
if not storage_ok or ledger.counter>=MAX then return nil end
local k=founder and region_key(p) or ""
local r=founder and region(k,time,false) or nil
if founder and (not r or #r.members>=FOUNDERS) then return nil end
ledger.counter=ledger.counter+1.0 -- avoid narrow-integer wrap; exact through MAX
if r then r.members[#r.members+1]={id=ledger.counter,species=id}; journal(k,r) end
if not persist() then return nil end
return ledger.counter,k
end
read_registry()
if not storage_ok then log("new wildlife needs a healthy resident registry; saved animals are retained") end
local function at(p,x,y,z) return {x=p.x+x,y=p.y+y,z=p.z+z} end
local function cell(p) return {x=math.floor(p.x+.5),y=math.floor(p.y+.5),z=math.floor(p.z+.5)} end
local function dist(a,b) return (a.x-b.x)^2+(a.y-b.y)^2+(a.z-b.z)^2 end
local function permitted(p)
if not valid(p) then return false end
if hearth.claim_owner_at then
local owner,ambiguous=hearth.claim_owner_at(p)
if owner or ambiguous then return false end
end
local protect=hearth.external_protected or api.is_protected
return type(protect)=="function" and not protect(p,"")
end
local function node(p)
if not permitted(p) then return nil end
local n=api.get_node_or_nil(p); return n and n.name~="ignore" and n or nil
end
local function season(time)
local climate=hearth.climate_at and hearth.climate_at(time)
return climate and climate.season or (hearth.seasons and hearth.seasons.season and hearth.seasons.season()) or nil
end
local markers={"owner","planter","placed_by","player_placed","hearth_placed","hearth_player_placed","planted_at"}
local function unmarked(p)
local m=api.get_meta(p)
for _,k in ipairs(markers) do local v=m:get_string(k); if v~="" and v~="0" then return false end end
if m.to_table then
for k,v in pairs((m:to_table() or {}).fields or {}) do
local harvest=k=="botany_ready_at" and tonumber(v)
and finite(tonumber(v)) and tonumber(v)>=0
if k:sub(1,5)~="succ_" and not harvest and v~="" and tostring(v)~="0" then return false end
end
end
return true
end
local function natural(p,n)
return n~=nil and api.get_meta(p):get_string("succ_natural")=="1" and unmarked(p)
end
local function water(n) return n and (n.name=="hearth:water_source" or n.name=="hearth:water_flowing") end
local function ground(n)
return n and (n.name=="hearth:grass" or n.name=="hearth:soil"
or (hearth.diversity and hearth.diversity.is_ground(n)))
end
local function legacy_flora(n)
local k=n and (hearth.seasons and hearth.seasons.kind(n) or n.name:match("^hearth:(.+)$"))
return k=="meadow_grass" or k=="fern" or k=="wildflowers"
end
local function clear(p)
local n=node(p)
if not n then return false end
if n.name=="air" then return true end
local d=(api.registered_nodes or {})[n.name]
local D=hearth.diversity
-- Collision observation is not a meal or adoption: recognizable unmarked
-- mapgen understory is passable even outside succession's adoption ring.
local below=at(p,0,-1,0)
local B=hearth.botany
local plant=B and B.get and B.get(n.name)
local flora=((D and D.is_worldgen_flora and D.is_worldgen_flora(n)) or legacy_flora(n)
or (plant and not plant.existing))
and unmarked(p) and unmarked(below) and ground(node(below))
return (natural(p,n) or flora) and d~=nil and d.walkable==false and not water(n)
end
local function medium(p,r)
if water(node(cell(at(p,0,.05,0)))) then
return (r.medium=="swim" or r.medium=="amphibian") and "swim" or nil
end
if r.medium=="swim" then return nil end
return r.medium=="fly" and "fly" or "land"
end
local function safe(p,id)
local r=records[id]; if not r or not valid(p) then return false end
local mode=medium(p,r); if not mode then return false end
local b=r.bounds; local radius=math.max(-b.min[1],b.max[1],-b.min[3],b.max[3])+.04
-- Full yaw-independent body envelope. Flight is a low corridor above
-- natural terrain, never a shortcut across cliffs or unobserved water.
for _,x in ipairs({-radius,0,radius}) do for _,z in ipairs({-radius,0,radius}) do
for _,y in ipairs({.03,b.max[2]*.5,b.max[2]+.08}) do
local q=cell(at(p,x,y,z))
if mode=="swim" then if not water(node(q)) then return false end
elseif not clear(q) then return false end
end
if mode~="swim" then
local supported=false
for depth=0,(mode=="fly" and 3 or 0) do
-- A Creatura spawn may start at integer ground-level while our
-- scanner starts at the half-node surface center. Sample below
-- either valid feet convention; the rest of the swept body uses
-- the same collision envelope and never teleports/creates terrain.
if ground(node(cell(at(p,x,-.6-depth,z)))) then supported=true; break end
end
if not supported then return false end
end
end end
return true,mode
end
local function path(a,b,id)
if not valid(a) or not valid(b) or dist(a,b)>4 then return false end
local _,start=safe(a,id); if not start then return false end
local length=math.sqrt(dist(a,b))
local steps=math.max(1,math.ceil(length/.2))
for i=0,steps do
local t=i/steps; local q={x=a.x+(b.x-a.x)*t,y=a.y+(b.y-a.y)*t,z=a.z+(b.z-a.z)*t}
local ok,m=safe(q,id)
-- Refuse shore transitions until a fully swept ramp exists.
if not ok or m~=start then return false end
end
return true
end
local function nearby(p,r,id,exclude)
local n=0
for e in pairs(active) do
local q=e.object:get_pos()
if e~=exclude and valid(q) and (not id or e.kind==id) and dist(p,q)<=r*r then n=n+1 end
end
return n
end
local function room(p,id,restored,exclude)
-- Caps are admission limits, never a reason to erase an existing animal
-- (including movement and engine activation before players join).
if restored then return true end
if not exclude and (total>=GLOBAL or counts[id]>=records[id].cap) then return false end
if nearby(p,16,nil,exclude)>=LOCAL then return false end
local close=false
for _,player in ipairs(players()) do
local q=player:get_pos()
if valid(q) and dist(p,q)<=RADIUS*RADIUS then
close=true; if nearby(q,RADIUS,nil,exclude)>=PLAYER then return false end
end
end
-- Saved creatures activate before the first player joins. Proximity gates
-- new immigration only, not persistent restoration.
return close
end
local function forget(e)
if active[e] then active[e]=nil; total=total-1; counts[e.kind]=counts[e.kind]-1 end
end
local function remove(e,death)
if not active[e] then return false end
forget(e); if death then deaths=deaths+1 end
e.object:remove(); return true
end
local function animation(e,name)
if e.clip==name then return end
e.clip=name; local c=records[e.kind].clips[name]
e.object:set_animation({x=c[1],y=c[2]},24,.15,true)
end
local function stop(e,clip)
e.target={x=0,y=0,z=0}; e.velocity={x=0,y=0,z=0}; e.lease=0
e.object:set_velocity(e.velocity); animation(e,clip or "idle")
end
local allowed={version=true,species=true,born_at=true,origin=true,sex=true,foodreserve=true,
resident_id=true,founder_region=true,hunger_seconds=true,
nextfeed=true,pregnancy_until=true,last_seen=true,seed=true,last_meal=true,dung_pending=true,
conception_at=true,litter_remaining=true,litter_delivered=true,litter_total=true,
nextbreed=true,last_birth_at=true,parent_seed=true,parent_born_at=true}
local reproductive={"conception_at","litter_remaining","litter_delivered","litter_total","nextbreed","last_birth_at"}
local function development(p)
return p and (p.reproduction=="egg" and p.incubation_days or p.gestation_days)
end
local function validate(s,id,time)
if type(s)~="table" or getmetatable(s)~=nil then return nil end
for k in pairs(s) do if not allowed[k] then return nil end end
if s.version~=1 or s.species~=id or not records[s.species] then return nil end
if s.resident_id~=nil or s.founder_region~=nil then
if not epoch(s.resident_id) or s.resident_id<1 or
(s.founder_region~="" and not valid_region(s.founder_region)) then return nil end
end
if s.hunger_seconds==nil then s.hunger_seconds=0 end -- legacy loaded hunger was not recorded
if not finite(s.hunger_seconds) or s.hunger_seconds<0 or s.hunger_seconds>MAX then return nil end
if not epoch(s.born_at) or not epoch(s.last_seen) or not epoch(s.last_meal)
or s.born_at>time or s.last_seen>time or s.last_seen<s.born_at
or s.last_meal>s.last_seen or s.last_meal<s.born_at then return nil end
if s.origin~="established" and s.origin~="born" then return nil end
if s.sex~="male" and s.sex~="female" and not (id=="earthworm" and s.sex=="hermaphrodite") then return nil end
if not finite(s.foodreserve) or s.foodreserve<0 or s.foodreserve>1 then return nil end
if not epoch(s.seed) or s.seed>2147483646 or not epoch(s.nextfeed) or s.nextfeed>time+DAY then return nil end
if type(s.dung_pending)~="boolean" or not epoch(s.pregnancy_until) then return nil end
-- Migrate only the old empty reproductive state, never an old pregnancy.
local missing=0; for _,k in ipairs(reproductive) do if s[k]==nil then missing=missing+1 end end
if missing==#reproductive and s.pregnancy_until==0 then
for _,k in ipairs(reproductive) do s[k]=0 end
elseif missing>0 then return nil end
if s.parent_seed==nil and s.parent_born_at==nil then s.parent_seed=0; s.parent_born_at=0 end
if not epoch(s.parent_seed) or s.parent_seed>2147483646 or not epoch(s.parent_born_at)
or s.parent_born_at>s.born_at then return nil end
for _,k in ipairs(reproductive) do if not epoch(s[k]) then return nil end end
if s.last_birth_at>s.last_seen then return nil end
local p=records[id].profile; local d=development(p)
local enabled=p and p.reproduction_enabled==true
local care=enabled and p.postpartum_days*DAY or 0
if s.nextbreed>time+care+(enabled and d.max*DAY or 0) then return nil end
if s.last_birth_at>0 and s.nextbreed<s.last_birth_at+care then return nil end
if s.pregnancy_until==0 then
if s.conception_at~=0 or s.litter_remaining~=0 or s.litter_total~=0 or s.litter_delivered~=0 then return nil end
else
if not enabled or s.sex~="female" or s.conception_at<s.born_at+p.maturity_days.max*DAY
or s.conception_at>s.last_seen
or (s.last_birth_at>s.conception_at and s.litter_delivered==0)
or s.pregnancy_until~=s.conception_at+math.ceil(d.max*DAY)
or s.pregnancy_until>time+math.ceil(d.max*DAY)
or s.litter_total<p.birth_count.min or s.litter_total>p.birth_count.max
or s.litter_remaining<1 or s.litter_delivered+s.litter_remaining~=s.litter_total
or (s.litter_delivered>0 and s.last_birth_at<s.pregnancy_until) then return nil end
end
return s
end
local function decode(data,id,time)
if not flat(data,2048) or not api.parse_json then return nil end
-- Flat schema only: reject deep parser input before decoding; suppress
-- engine JSON diagnostics so hostile raw data never reaches the logs.
if data:sub(1,1)~="{" or data:sub(-1)~="}" or data:sub(2,-2):find("[{}%[%]]") then return nil end
local ok,s=pcall(api.parse_json,data,nil,true)
return ok and validate(s,id,time) or nil
end
local function encode(s,id,time)
if not validate(s,id,time) or not api.write_json then return nil end
local ok,text=pcall(api.write_json,s)
return ok and type(text)=="string" and #text<=2048 and text or nil
end
local function initial(id,time,seed)
local p=records[id].profile; local maturity=p and p.maturity_days; local age=0
-- Upper maturity endpoint yields an explicitly estimated adult cohort,
-- not a random juvenile advertised as an established adult.
if maturity then age=maturity.max*DAY end
return {version=1,species=id,born_at=math.max(0,time-math.ceil(age)),origin="established",
hunger_seconds=0,
sex=id=="earthworm" and "hermaphrodite" or (seed%2==0 and "female" or "male"),
-- Imported wild individuals are not credited a simulated meal.
foodreserve=.5,nextfeed=time,pregnancy_until=0,last_seen=time,seed=seed,last_meal=time,dung_pending=false,
conception_at=0,litter_remaining=0,litter_delivered=0,litter_total=0,nextbreed=0,last_birth_at=0,
parent_seed=0,parent_born_at=0}
end
local function prey_for(e,p,range)
local best,found=range*range,nil
for other in pairs(active) do
local q=other.object:get_pos()
if other~=e and records[e.kind].prey[other.kind] and valid(q) and dist(p,q)<best
and safe(q,other.kind) then found,best=other,dist(p,q) end
end
return found,best
end
local function edible(p,n,time,food)
if not natural(p,n) then return nil end
local B=hearth.botany; local s=season(time)
if B and B.kind(n) then
local id=B.kind(n)
if food=="nectar" and B.pollinator(n,s) then return id end
if food=="fruit" and B.edible(n,s) then return id end
if food=="browse" and B.has_tag(n,"browse") then
if B.browse(n,s) then return id,"herbivore" end
-- A tagged, non-toxic woody perennial may offer twigs/stems while
-- dormant. This is not human edibility; the shared forage transaction
-- must explicitly accept the winter_browse mode before credit is earned.
local r=B.get(id)
if s=="Winter" and r and (r.group=="trees" or r.group=="shrubs") and not r.toxic then
return id,"winter_browse"
end
end
-- Succession currently debits only represented oak/hazel nuts, not
-- every herb's qualitative seeding season as fictional bird food.
if food=="seed" and (id=="hazel" or id=="oak") and B.seeding(n,s) then return id end
return nil
end
local k=hearth.seasons and hearth.seasons.kind(n.name) or n.name:match("^hearth:(.+)$")
if food=="browse" and (k=="meadow_grass" or k=="fern") then
if not hearth.seasons or hearth.seasons.available(k) then return k,"herbivore" end
if s=="Autumn" or s=="Winter" then return k,"winter_browse" end
end
end
local function resources(e,p,time,foods)
foods=foods or records[e.kind].foods
local c=cell(at(p,0,.1,0)); local candidates={}
-- A fixed 5x5 search finds nearby food without scaling with population or
-- walking the map; path validation still decides whether it is reachable.
for x=-2,2 do for z=-2,2 do
local q=at(c,x,0,z); local n=node(q)
if natural(q,n) then
for _,food in ipairs({"nectar","litter","fruit","seed","browse"}) do
if foods[food] then
local id,browse_mode
if food=="litter" and n.name=="hearth:litter" then id="litter"
else id,browse_mode=edible(q,n,time,food) end
if id then candidates[#candidates+1]={pos=q,id=id,food=food,browse_mode=browse_mode} end
end
end
end
end end
return candidates
end
local function observe_habitat(p)
local S=hearth.succession
if not S or not S.habitat then return nil end
local q=cell(at(p,0,.1,0))
-- Succession observes the stand ABOVE a water substrate, not its bottom.
if water(node(q)) then q=at(q,0,1,0) end
local h=S.habitat(q)
return h and h.loaded==true and h or nil
end
local function habitat(p,id,time,observed,food,allow_ambient)
local h=observed or observe_habitat(p)
if not h then return false end
local r=records[id]
if r.medium=="amphibian" or r.medium=="swim" then
if (h.water or 0)+(h.near_water or 0)<=0 then return false end
end
if id=="earthworm" or id=="common_frog" or id=="smooth_newt" then
if (h.moisture or 0)<.5 then return false end
end
if id=="red_squirrel" and (h.canopy or 0)<=0 then return false end
if (id=="bank_vole" or id=="wood_mouse") and (h.cover or 0)<=0 then return false end
local probe={kind=id}
local ambient={robin=true,blackbird=true,blue_tit=true}
local function ambient_patch()
if not allow_ambient or not ambient[id] then return false end
local vegetation=(h.grass or 0)+(h.flowers or 0)+(h.cover or 0)+(h.canopy or 0)
local water=(h.water or 0)+(h.near_water or 0)
return vegetation>0 and (r.medium~="amphibian" or water>0)
end
if food then
for _,v in ipairs(food) do if r.foods[v.food] then return true end end
if prey_for(probe,p,16) then return true end
-- A small set of common ambient species may establish in a genuinely
-- vegetated patch before a modeled feeding transaction is available.
-- This grants no food or meal credit; all later feeding still requires
-- the normal shared succession/prey transaction.
return ambient_patch()
end
return #resources(probe,p,time)>0 or prey_for(probe,p,16)~=nil or ambient_patch()
end
local function stencil(p)
-- Existing forage/deposit may mutate the entire 3x3 neighbourhood.
for x=-1,1 do for z=-1,1 do for y=-1,1 do
if not node(at(p,x,y,z)) then return false end
end end end
return true
end
local function move(e,dx,dy,dz)
local r=records[e.kind]; local p=e.object:get_pos()
local len=math.sqrt(dx*dx+dy*dy+dz*dz); if len<.001 then stop(e); return false end
local speed=math.min(r.speed,1.3)
local b=at(p,dx/len*speed,dy/len*speed,dz/len*speed)
if not path(p,b,e.kind) or not room(b,e.kind,true,e) then stop(e); return false end
-- AI thinks every two seconds; retain motion through that interval and
-- leave a short grace margin so scheduling jitter does not cancel travel.
e.target={x=dx/len*speed,y=dy/len*speed,z=dz/len*speed}; e.lease=2.5
local _,mode=safe(p,e.kind)
animation(e,(mode=="swim" or mode=="fly") and "flight_swim" or "locomotion")
return true
end
-- Songbirds loosely gather around nearby members of their own species.
-- This is derived from the bounded active-resident set, not a persistent
-- flock/group assignment; unloaded wildlife retains the same save schema.
local flocking={robin=true,blackbird=true,blue_tit=true,mallard=true}
local function flock_direction(e,p,dx,dz)
if not flocking[e.kind] then return dx,dz end
local C=hearth.creatura
if C and type(C.get_boid_dir)=="function" then
local ok,direction=pcall(C.get_boid_dir,e)
if ok and valid(direction) then return dx+direction.x*1.35,dz+direction.z*1.35 end
end
local count,sx,sz,separate_x,separate_z=0,0,0,0,0
for other in pairs(active) do
if other~=e and other.kind==e.kind then
local q=other.object:get_pos()
if valid(q) then
local ox,oz=q.x-p.x,q.z-p.z; local d2=ox*ox+oz*oz
if d2>0 and d2<=100 then
count=count+1; sx=sx+q.x; sz=sz+q.z
if d2<4 then
separate_x=separate_x-ox/d2; separate_z=separate_z-oz/d2
end
end
end
end
end
if count==0 then return dx,dz end
local cx,cz=sx/count-p.x,sz/count-p.z
local distance=math.sqrt(cx*cx+cz*cz)
if distance>.001 then
local strength=1.25*math.min(1,distance/6)
dx=dx*.35+cx/distance*strength; dz=dz*.35+cz/distance*strength
end
local separation=math.sqrt(separate_x*separate_x+separate_z*separate_z)
if separation>.001 then
dx=dx+separate_x/separation*1.5; dz=dz+separate_z/separation*1.5
end
return dx,dz
end
local function interaction_player(clicker)
if type(api.get_connected_players)~="function" or type(hearth.sessions)~="table" then return nil end
local ok,name=pcall(function() return clicker:get_player_name() end)
if not ok or type(name)~="string" or #name<1 or #name>64 or not hearth.sessions[name] then return nil end
local connected_ok,list=pcall(api.get_connected_players)
if not connected_ok or type(list)~="table" then return nil end
for _,player in pairs(list) do if player==clicker then return name end end
return nil
end
local function interaction_snapshot(self)
-- Scalar-only copy: the UI receives no live entity/state table reference.
local source=self.state
return {born_at=source.born_at,origin=source.origin,foodreserve=source.foodreserve,
resident=source.resident_id~=nil,observation=self.clip=="rest" and "rest" or "awake"}
end
local function activity_phase(e,time)
local r=records[e.kind]
local schedule=r.sleep or {}
if r.winter_dormant and season(time)=="Winter" then return "sleep" end
local climate=hearth.climate_at and hearth.climate_at(time)
local date=os.date("!*t",time)
local hour=climate and finite(climate.hour) and climate.hour%24 or date.hour
local elevation=climate and finite(climate.elevation) and climate.elevation
or (hour>=6 and hour<18 and 1 or -20)
local activity=r.activity
if activity=="day" then
if elevation<=(schedule.sleep_below or -6) then return "sleep" end
if elevation<(schedule.winddown_below or 6) then return "winddown" end
return "active"
elseif activity=="night" then
if elevation>=(schedule.sleep_above or 6) then return "sleep" end
if elevation>(schedule.winddown_above or -6) then return "winddown" end
return "active"
elseif activity=="crepuscular" then
local light=schedule.active_between or {-8,8}
local rest=schedule.rest_hours or {11,15}
if elevation>=light[1] and elevation<=light[2] and (hour<rest[1] or hour>=rest[2]) then return "active" end
if elevation>=light[1]-4 and elevation<=light[2]+4 then return "winddown" end
return "sleep"
elseif activity=="any" then
local rest=schedule.rest_hours or {12,14}
local winddown=schedule.winddown_hours or {11.5,14.5}
if hour>=rest[1] and hour<rest[2] then return "sleep" end
if (hour>=winddown[1] and hour<rest[1]) or (hour>=rest[2] and hour<winddown[2]) then return "winddown" end
return "active"
end
-- Unknown activity is deliberately conservative, not permanently awake.
local light=schedule.active_between or {-8,8}
local rest=schedule.rest_hours or {11,15}
if elevation>=light[1] and elevation<=light[2] and (hour<rest[1] or hour>=rest[2]) then return "active" end
if elevation>=light[1]-4 and elevation<=light[2]+4 then return "winddown" end
return "sleep"
end
local function awake(e,time) return activity_phase(e,time)=="active" end
-- One bounded 9x9x8 (648-node) terrain query per five active seconds.
-- Never emerge blocks, scan every species, or retain stale terrain results.
local function candidate(center)
patch_cursor=patch_cursor%#offsets+1
local d=offsets[patch_cursor]; local c=cell(at(center,d[1],0,d[2]))
if not valid(at(c,-4,-4,-4)) or not valid(at(c,4,3,4)) then return nil end
local names={"hearth:grass","hearth:soil","hearth:water_source","hearth:water_flowing"}
local D=hearth.diversity
for name in pairs(api.registered_nodes or {}) do
if (D and ((D.is_ground and D.is_ground(name))
or (D.is_worldgen_flora and D.is_worldgen_flora(name))))
or legacy_flora({name=name}) then names[#names+1]=name end
end
if not api.find_nodes_in_area_under_air then return nil,c end
recruiting.scans=recruiting.scans+1
local found=api.find_nodes_in_area_under_air(at(c,-4,-4,-4),at(c,4,3,4),names)
local candidates={}
for i=1,math.min(#found,648) do
local q=found[i]
if valid(q) then
local n=node(q)
local p=n and {x=q.x,y=q.y+(ground(n) and .5 or (water(n) and 0 or -.5)),z=q.z}
local dx,dz=q.x-center.x,q.z-center.z
if p and dx*dx+dz*dz>=144 and dx*dx+dz*dz<=576 then candidates[#candidates+1]=p end
end
end
table.sort(candidates,function(a,b)
if a.x~=b.x then return a.x<b.x end
if a.z~=b.z then return a.z<b.z end
return a.y<b.y
end)
if #candidates==0 then return nil,c end
candidate_cursor=candidate_cursor+1
return candidates[(candidate_cursor-1)%#candidates+1],c
end
local function recruit(time)
local function result(reason)
recruiting.last_reason=reason
recruiting.reasons[reason]=(recruiting.reasons[reason] or 0)+1
end
local observers=players()
if #observers==0 then result("no_players"); return end
recruiting.attempts=recruiting.attempts+1
if not storage_ok then result("registry_unavailable"); return end
if total>=GLOBAL then result("global_cap"); return end
player_cursor=player_cursor%#observers+1
local center=observers[player_cursor]:get_pos()
if not valid(center) then result("invalid_player"); return end
local p,patch=candidate(center)
if not p and not patch then result("no_surface"); return end
local k=region_key(p or patch)
local r=region(k,time,true)
if not r then result(storage_ok and "region_budget" or "registry_unavailable"); return end
if #r.members>=FOUNDERS then result("region_seeded"); return end
if not p then result("no_surface"); return end
for _,player in ipairs(observers) do
local q=player:get_pos()
if valid(q) and dist(p,q)<64 then result("observer_near"); return end
end
local anchor=cell(at(p,0,.1,0))
if not stencil(anchor) then result("blocked_or_unloaded"); return end
-- Guarded natural-worldgen adoption only; never grant food, edit crops,
-- or fabricate botanical ages. Succession owns all subsequent debits.
local D=hearth.diversity
if D and type(D.bootstrap)=="function" then
for x=-1,1 do for z=-1,1 do
local q=at(anchor,x,0,z); local n=node(q)
local below=at(q,0,-1,0)
if n and unmarked(q) and unmarked(below) and ground(node(below)) then
D.bootstrap(q,n)
-- The legacy three ground sprites predate diversity's catalogue.
-- Adopt only provenance, never an age/profile or a ration.
if legacy_flora(n) then api.get_meta(q):set_string("succ_natural","1") end
end
end end
end
local h=observe_habitat(p)
if not h then result("habitat_unloaded"); return end
-- Collect represented plant foods once, then match the catalogue in
-- rotating order. Failed species do not trigger 23 terrain/food scans.
local food={}; local seen={}
for _,id in ipairs(order) do
for k in pairs(records[id].foods) do seen[k]=true end
end
food=resources(nil,p,time,seen)
local reason="no_active_food_match"
for i=1,#order do
local index=(recruit_kind+i-1)%#order+1; local id=order[index]
if counts[id]<records[id].cap and awake({kind=id},time) and habitat(p,id,time,h,food,false) then
if not room(p,id,false) then result("population_cap"); return end
if safe(p,id) then
recruit_kind=index
local before=total
local ok=pcall(api.add_entity,p,"hearth:"..id)
if ok and total>before then recruiting.successes=recruiting.successes+1; result("admitted")
else result("activation_rejected") end
return
end
reason="unsafe_body"
end
end
-- When no species has a represented food item, admit at most one small
-- songbird founder on a loaded natural vegetated patch. This ambient
-- observation grants no reserve or meal and never bypasses later feeding.
if reason=="no_active_food_match" then
for _,id in ipairs({"robin","blackbird","blue_tit"}) do
local index
for i,value in ipairs(order) do if value==id then index=i; break end end
if index and counts[id]<records[id].cap and awake({kind=id},time)
and habitat(p,id,time,h,nil,true) then
if not room(p,id,false) then result("population_cap"); return end
if safe(p,id) then
recruit_kind=index; local before=total
local ok=pcall(api.add_entity,p,"hearth:"..id)
if ok and total>before then recruiting.successes=recruiting.successes+1; result("admitted_ambient")
else result("activation_rejected") end
return
end
reason="unsafe_body"
end
end
end
recruit_kind=recruit_kind%#order+1; result(reason)
end
local function earned(e,time)
local s=e.state; s.foodreserve=math.min(1,s.foodreserve+.25); s.last_meal=time
s.hunger_seconds=0
s.nextfeed=time+records[e.kind].feed_seconds
s.dung_pending=e.kind~="bumblebee" and e.kind~="earthworm"
stop(e,"feed")
end
local function feed(e,p,time)
local s=e.state; if time<s.nextfeed then return false end
if not stencil(cell(at(p,0,.1,0))) then return false end
local S=hearth.succession or {}; local r=records[e.kind]
local prey,d=prey_for(e,p,12)
if prey then
local q=prey.object:get_pos()
if d<=.8*.8 and path(p,q,e.kind) and safe(q,prey.kind) and remove(prey,true) then
meals=meals+1; earned(e,time); return true
end
if move(e,q.x-p.x,q.y-p.y,q.z-p.z) then return true end
end
local pursuit,pursuit_distance
for _,v in ipairs(resources(e,p,time)) do
local q=v.pos
if dist(p,q)<=2.25 and stencil(q) then
local success=false
if v.food=="nectar" and type(S.pollinate)=="function" then
-- Shared per-flower lock belongs to succession, not per-bee memory.
success=S.pollinate(q)==true; if success then pollinations=pollinations+1 end
elseif v.food=="litter" and type(S.detritivore)=="function" then
success=S.detritivore(q)==true; if success then detritus=detritus+1 end
elseif v.food=="fruit" and type(S.frugivore)=="function" then
success=S.frugivore(q,v.id)==true
elseif v.food=="seed" and type(S.granivore)=="function" then
local consumed,cache=S.granivore(q,v.id)
success=consumed==true
-- A second, separately debited viable nut is required for caching:
-- never eat and plant the same seed. No token survives unload.
if success and cache==v.id and e.kind=="red_squirrel" and (v.id=="hazel" or v.id=="oak") and S.disperse_seed then
local dest=cell(at(p,1,0,0))
if stencil(dest) and S.disperse_seed(dest,v.id)==true then dispersals=dispersals+1 end
end
elseif v.food=="browse" and type(S.forage)=="function" then
success=S.forage(q,v.browse_mode or "herbivore")==true; if success then grazed=grazed+1 end
end
if success then earned(e,time); return true end
end
local d=dist(p,q)
if (not pursuit_distance or d<pursuit_distance) and d>2.25 then pursuit,pursuit_distance=q,d end
end
if pursuit then return move(e,pursuit.x-p.x,0,pursuit.z-p.z) end
return false
end
local spawning
local function healthy(e,time)
local s=e.state
return active[e] and time>=s.last_seen and time>=s.last_meal and s.foodreserve>=.7
and time-s.last_meal<=records[e.kind].feed_seconds*2
end
local function reproduce(e,p,time)
local s=e.state; local profile=records[e.kind].profile
if not profile or profile.reproduction_enabled~=true or s.sex~="female" or e.delivering or spawning then return false end
if s.pregnancy_until>0 then
if time<s.pregnancy_until or s.foodreserve<.2 or credit<1 or not habitat(p,e.kind,time) then return false end
-- One child per budgeted attempt, not an unbounded litter loop. Full
-- caps/habitat delay the SAME deadline and preserve the remainder.
local destination=at(p,.25,0,.25)
if not safe(destination,e.kind) or not room(destination,e.kind,false) then return false end
credit=credit-1; actions=actions+1
local child=initial(e.kind,time,(s.seed+time+s.litter_delivered+1.0)%2147483647.0)
child.origin="born"; child.born_at=time; child.foodreserve=.1
child.parent_seed=s.seed; child.parent_born_at=s.born_at
child.resident_id,child.founder_region=identity(e.kind,destination,time,false)
if not child.resident_id then return false end
local text=encode(child,e.kind,time); if not text then return false end
-- Reserve before the engine callback; synchronous reentry cannot pay
-- the same child twice. Acknowledgement handles nil ObjectRef results.
e.delivering=true
s.litter_remaining=s.litter_remaining-1; s.litter_delivered=s.litter_delivered+1
spawning={text=text,id=e.kind,ack=false}
local ok,obj=pcall(api.add_entity,destination,"hearth:"..e.kind,text)
local admitted=spawning.ack; spawning=nil; e.delivering=false
if not admitted then
s.litter_remaining=s.litter_remaining+1; s.litter_delivered=s.litter_delivered-1
if ok and obj and obj.remove then obj:remove() end
return false
end
births=births+1; s.foodreserve=math.max(0,s.foodreserve-.1)
s.last_birth_at=time; s.nextbreed=time+profile.postpartum_days*DAY
if s.litter_remaining==0 then
s.pregnancy_until=0; s.conception_at=0; s.litter_total=0; s.litter_delivered=0
end
stop(e,"rest"); return true
end
if time<s.nextbreed or activity_phase(e,time)~="active" or not healthy(e,time)
or not F.can_breed(e.kind,s.born_at,time,s.sex,season(time))
or not habitat(p,e.kind,time) or not room(p,e.kind,false) then return false end
local mate
for other in pairs(active) do
local q=other.object:get_pos(); local o=other.state
if other~=e and other.kind==e.kind and o.sex=="male" and valid(q) and dist(p,q)<=4
and activity_phase(other,time)=="active" and healthy(other,time) and time>=o.nextbreed
and safe(q,other.kind) and habitat(q,other.kind,time)
and F.can_breed(other.kind,o.born_at,time,o.sex,season(time))
and (not mate or other.id<mate.id) then mate=other end
end
if not mate then return false end
s.conception_at=time; s.pregnancy_until=time+math.ceil(development(profile).max*DAY)
s.litter_total=profile.birth_count.min -- conservative sourced count, not guaranteed survival
s.litter_remaining=s.litter_total; s.litter_delivered=0
s.foodreserve=s.foodreserve-.2; mate.state.foodreserve=mate.state.foodreserve-.2
mate.state.nextbreed=time+DAY -- no same-tick unlimited male mating credit
stop(e,"rest"); return true
end
local function dependent(e,p,time)
local s=e.state; local profile=records[e.kind].profile
if s.origin~="born" or not profile or not profile.juvenile_days
or time-s.born_at>=profile.juvenile_days.max*DAY then return false end
-- Dependent young do not hunt or browse like adults. Bounded maternal
-- reserve transfer is coarse care accounting, not simulated milk/nests.
if time>=s.nextfeed then
for mother in pairs(active) do
local m=mother.state; local q=mother.object:get_pos()
if mother.kind==e.kind and m.sex=="female" and m.seed==s.parent_seed and m.born_at==s.parent_born_at
and valid(q) and dist(p,q)<=4 and safe(q,mother.kind) and m.foodreserve>=.3 then
m.foodreserve=m.foodreserve-.05; s.foodreserve=math.min(1,s.foodreserve+.05)
s.last_meal=time; s.hunger_seconds=0; s.nextfeed=time+records[e.kind].feed_seconds; break
end
end
end
stop(e,"rest"); return true
end
local function think(e,time)
local p=e.object:get_pos()
local s=e.state
if time<s.last_seen then stop(e,"rest"); return end
if not safe(p,e.kind) or #players()==0 then s.last_seen=time; stop(e,"rest"); return end
-- Energy observes bounded loaded intervals; calendar age remains absolute.
local elapsed=math.min(60,time-s.last_seen)
s.foodreserve=math.max(0,s.foodreserve-elapsed/records[e.kind].reserve_seconds)
s.hunger_seconds=math.min(MAX,s.hunger_seconds+elapsed)
s.last_seen=time
local r=records[e.kind]
if r.starvation_days and s.hunger_seconds>=r.starvation_days*DAY then remove(e,true); return end
-- Threat response precedes all resting, including winter dormancy. A
-- sleeping prey animal can still flee/curl when danger enters its radius.
local danger,best=nil,64 -- eight-node observer/predator flight radius
for _,player in ipairs(players()) do
local q=player:get_pos(); if valid(q) and dist(p,q)<best then danger,best=q,dist(p,q) end
end
for other in pairs(active) do
local q=other.object:get_pos()
if records[other.kind].prey[e.kind] and valid(q) and dist(p,q)<best then danger,best=q,dist(p,q) end
end
if danger then
if e.kind=="hedgehog" then stop(e,"rest") else move(e,p.x-danger.x,0,p.z-danger.z) end
return
end
if dependent(e,p,time) then return end
if reproduce(e,p,time) then return end
if activity_phase(e,time)~="active" then stop(e,"rest"); return end
if s.dung_pending and time-s.last_meal>=3600 then
s.dung_pending=false; local q=cell(at(p,0,.1,0)); local S=hearth.succession
if stencil(q) and S and S.deposit and S.deposit(q)==true then dung=dung+1 end
return
end
if feed(e,p,time) then return end
e.turn=e.turn+1
if e.turn%3==0 then stop(e); return end
local d=directions[(e.id+e.turn)%#directions+1]
local dx,dz=flock_direction(e,p,d[1],d[2])
local vertical=(r.medium=="fly" and e.turn%5==0) and .25 or 0
move(e,dx,vertical,dz)
end
for _,id in ipairs(order) do
local r=records[id]; local b=r.bounds
api.register_entity("hearth:"..id,{
collisionbox={b.min[1],0,b.min[3],b.max[1],b.max[2],b.max[3]},
initial_properties={physical=true,collide_with_objects=false,collisionbox={b.min[1],0,b.min[3],b.max[1],b.max[2],b.max[3]},
visual="mesh",mesh=r.mesh,textures={r.texture},visual_size={x=1,y=1},static_save=true,
pointable=true,makes_footstep_sound=false,eye_height=r.eye_height},
on_activate=function(self,data,dtime_s)
if active[self] then return end
local time=now(); local p=self.object:get_pos(); local restored=type(data)=="string" and #data>0
local state=time and restored and decode(data,id,time) or nil
local newborn=spawning and spawning.id==id and spawning.text==data
if (data~=nil and type(data)~="string") or not time or (restored and not state) or (not restored and dtime_s and dtime_s~=0)
or not valid(p) then log("rejected invalid wildlife snapshot or position"); self.object:remove(); return end
if (not restored or newborn) and (not safe(p,id) or not room(p,id,false)
or not habitat(p,id,time,nil,nil,not restored)) then
self.object:remove(); return
end
state=state or initial(id,time,(serial+1)%2147483647)
if restored and state.resident_id and storage_ok and state.resident_id>ledger.counter then
-- A missing/rolled-back ledger is not permission to reuse IDs.
fail_storage()
end
if not state.resident_id then
state.resident_id,state.founder_region=identity(id,p,time,not restored)
-- A storage fault must not erase a validated legacy resident.
if not restored and not state.resident_id then self.object:remove(); return end
end
serial=serial+1; self.id=serial; self.kind=id; self.turn=0
-- Creatura's bounded boid cache expects these standard entity fields.
self.name="hearth:"..id; self.max_boids=5; self.tracking_range=10
self.width=math.max(b.max[1],-b.min[1]); self._movement_data={}
self.state=state
if restored then self.state.last_seen=time; self.state.dung_pending=false end
self.next_ai=clock; self.velocity={x=0,y=0,z=0}; self.target={x=0,y=0,z=0}; self.lease=0
active[self]=true; counts[id]=counts[id]+1; total=total+1
if spawning and spawning.id==id and spawning.text==data then spawning.ack=true end
self.object:set_armor_groups({immortal=1})
self.object:set_acceleration({x=0,y=0,z=0}) -- guarded horizontal corridors
stop(self)
end,
get_staticdata=function(self)
local time=now(); if not time or not self.state then return "" end
return encode(self.state,id,time) or "invalid"
end,
on_step=function(self,dt)
if not active[self] then return end
local t=delta(dt); if t==0 then return end
local p=self.object:get_pos()
if not safe(p,id) or self.lease<=0 then
stop(self,(self.clip=="feed" or self.clip=="rest") and self.clip or "idle"); return
end
local v=self.velocity; local target=self.target; local limit=t*2
local dx,dy,dz=target.x-v.x,target.y-v.y,target.z-v.z
local len=math.sqrt(dx*dx+dy*dy+dz*dz); local ratio=len>limit and limit/len or 1
local nextv={x=v.x+dx*ratio,y=v.y+dy*ratio,z=v.z+dz*ratio}
local horizon=math.max(.15,t)
if not path(p,at(p,nextv.x*horizon,nextv.y*horizon,nextv.z*horizon),id) then stop(self); return end
self.velocity=nextv; self.lease=math.max(0,self.lease-t); self.object:set_velocity(nextv)
if math.abs(nextv.x)+math.abs(nextv.z)>.001 then
local yaw=(math.atan2 or math.atan)(-nextv.x,nextv.z); self.object:set_yaw(yaw)
end
end,
on_rightclick=function(self,clicker)
if not active[self] or not records[self.kind] or not self.state or not interaction_player(clicker) then return end
local inspect=hearth.inspect_fauna
if type(inspect)=="function" then pcall(inspect,clicker,self.kind,interaction_snapshot(self)) end
end,
on_punch=function(self,puncher)
-- A hit only triggers the existing flee movement; immortal armor and
-- the absence of drops/death handlers keep this noncombat.
if active[self] and puncher then
local ok,source=pcall(function() return puncher:get_pos() end)
local p=self.object:get_pos()
if ok and valid(source) and valid(p) then move(self,p.x-source.x,0,p.z-source.z) end
end
return true
end,on_deactivate=forget,
})
end
-- Use Creatura's well-tested ambient spawn API for small, visible groups.
-- Every callback still passes Last Hearth's registry, claims, protection,
-- collision, habitat and per-player/global caps in on_activate.
local C=hearth.creatura
if C and type(C.register_abm_spawn)=="function" then
local group={robin={"hearth:grass","hearth:forest_floor","hearth:podzol"},
blackbird={"hearth:grass","hearth:forest_floor","hearth:podzol","hearth:alluvium"},
blue_tit={"hearth:grass","hearth:forest_floor","hearth:podzol"},
hare={"hearth:grass","hearth:alpine_turf","hearth:alluvium"},
rabbit={"hearth:grass","hearth:forest_floor","hearth:alluvium"},
deer={"hearth:grass","hearth:forest_floor","hearth:podzol"},
roe_deer={"hearth:grass","hearth:forest_floor","hearth:podzol"},
red_squirrel={"hearth:forest_floor","hearth:podzol"},
bank_vole={"hearth:grass","hearth:forest_floor","hearth:podzol","hearth:peat"},
wood_mouse={"hearth:grass","hearth:forest_floor","hearth:podzol","hearth:peat"}}
for id,nodes in pairs(group) do
local ok,err=pcall(C.register_abm_spawn,"hearth:"..id,{
chance=1800,interval=45,min_height=-1,max_height=240,min_light=0,max_light=15,
min_group=1,max_group=2,spawn_cap=records[id].cap,nodes=nodes,neighbors={"air"},spawn_active=true})
if not ok then log("Creatura spawn registration failed for "..id..": "..tostring(err)) end
end
end
api.register_globalstep(function(dt)
local t=delta(dt); if t==0 then return end
clock=clock+t; credit=math.min(BUDGET,credit+t*BUDGET)
-- No recruitment credit banks while nobody is observing this world.
recruitment=#players()>0 and math.min(5,recruitment+t) or 0
local time=now(); if not time then return end
local list={}; for e in pairs(active) do list[#list+1]=e end
table.sort(list,function(a,b) return a.id<b.id end)
if recruitment>=5 and credit>=1 then
recruitment=0; credit=credit-1; actions=actions+1
recruit(time)
end
for _=1,#list do
if credit<1 then break end
cursor=cursor%#list+1; local e=list[cursor]
if active[e] and clock>=e.next_ai then
credit=credit-1; actions=actions+1; e.next_ai=clock+2; think(e,time)
end
end
end)
return {species="hearth:hare",fauna=F,global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL,ai_budget=BUDGET,
status=function()
local c={}; for id,n in pairs(counts) do c[id]=n end
local diagnostics={interval=5,scan_nodes=648,attempts=recruiting.attempts,
successes=recruiting.successes,scans=recruiting.scans,last_reason=recruiting.last_reason,reasons={}}
for k,v in pairs(recruiting.reasons) do diagnostics.reasons[k]=v end
return {total=total,hare=c.hare,deer=c.deer,fox=c.fox,counts=c,species_count=23,
residents={storage_ok=storage_ok,regions=region_count,region_limit=REGION_LIMIT,
region_size=REGION,founders_per_region=FOUNDERS,counter=ledger.counter},
recruitment=diagnostics,
global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL,actions=actions,births=births,
meals=meals,grazed=grazed,dung=dung,deaths=deaths,pollinations=pollinations,dispersals=dispersals,detritus=detritus}
end}
end