-- 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)=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_seens.last_seen or s.last_meal1 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.nextbreeds.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_totalp.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_at0 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[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=winddown[1] and hour=rest[2] and hour=light[1] and elevation<=light[2] and (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=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]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]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 time2.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=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=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=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)=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=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