-- Loaded-only fauna: bounded work, no drops, combat, restores or catch-up. return function(hearth, api) local GLOBAL, LOCAL, PLAYER, RADIUS, BUDGET = 32, 6, 6, 48, 12 local kinds = {"hare", "deer", "fox"} local species = { hare={cap=16,radius=0.23,height=0.85,body=0.48,length=0.32}, deer={cap=10,radius=0.32,height=1.52,body=1.12,length=0.49}, fox={cap=6,radius=0.23,height=0.80,body=0.57,length=0.43}, } local active, counts, total, serial = {}, {hare=0,deer=0,fox=0}, 0, 0 local clock, credit, recruitment, player_cursor, recruit_kind, cursor = 0, 0, 0, 0, 0, 0 local actions, births, meals, grazed, dung = 0, 0, 0, 0, 0 local directions={{1,0},{1,1},{0,1},{-1,1},{-1,0},{-1,-1},{0,-1},{1,-1}} local offsets={{8,0},{-8,0},{0,8},{0,-8},{16,8},{-16,-8},{24,0},{0,24}} local function delta(dt) if type(dt)~="number" or dt~=dt or dt<0 or dt==math.huge then return 0 end return math.min(dt,1) end local function valid(p) if type(p)~="table" then return false end for _, axis in ipairs({"x","y","z"}) do local n=p[axis] if type(n)~="number" or n~=n or math.abs(n)>30900 then return false end end return true 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+0.5),y=math.floor(p.y+0.5),z=math.floor(p.z+0.5)} end local function distance(a,b) return (a.x-b.x)^2+(a.y-b.y)^2+(a.z-b.z)^2 end local function permitted(p) 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 valid(p) or not permitted(p) then return nil end local n=api.get_node_or_nil(p) if n and n.name~="ignore" then return n end end local function natural(p,n) if not n or api.get_meta(p):get_string("succ_natural")~="1" then return false end local k=hearth.seasons and hearth.seasons.kind(n) or n.name:match("^hearth:(.+)$") if hearth.seasons and not hearth.seasons.available(k) then return false end if hearth.diversity then return hearth.diversity.edible(k) end return k=="meadow_grass" or k=="fern" end local function clear(p) local n=node(p) return n and (n.name=="air" or natural(p,n) or n.name=="hearth:dung") end local function safe(p,kind) if not valid(p) or not permitted(p) then return false end local s=species[kind] -- A yaw-independent square body envelope; only flat natural terrain. local r=math.max(s.radius,s.length) for _, x in ipairs({-r,0,r}) do for _, z in ipairs({-r,0,r}) do local n=node(cell(at(p,x,-0.05,z))) if not n or not (n.name=="hearth:grass" or n.name=="hearth:soil" or (hearth.diversity and hearth.diversity.is_ground(n))) then return false end for _, y in ipairs({0.05,1.1,s.height,2.1}) do if not clear(cell(at(p,x,y,z))) then return false end end end end return true end local function path(a,b,kind) if not valid(a) or not valid(b) or distance(a,b)>2.26 then return false end for i=0,6 do local t=i/6 if not safe({x=a.x+(b.x-a.x)*t,y=a.y+(b.y-a.y)*t,z=a.z+(b.z-a.z)*t},kind) then return false end end return true end local function habitat(p) local s=hearth.succession if not s or not s.habitat then return false end local c=cell(at(p,0,0.1,0)) local h=s.habitat(c) if not h or h.loaded~=true or ((h.grass or 0)<=0 and (h.cover or 0)<=0 and (h.edible or 0)<=0) then return false end -- cover also counts trees: a forest without edible meadow/fern is not -- a recruitment or breeding site. Read at most nine loaded cells. for x=-1,1 do for z=-1,1 do local q=at(c,x,0,z) if natural(q,node(q)) then return true end end end return false end local function nearby(p,r,kind,exclude) local n=0 for e in pairs(active) do local q=e.object:get_pos() if e~=exclude and valid(q) and (not kind or e.kind==kind) and distance(p,q)<=r*r then n=n+1 end end return n end local function room(p,kind) if total>=GLOBAL or counts[kind]>=species[kind].cap or nearby(p,16)>=LOCAL then return false end local close=false for _, player in ipairs(api.get_connected_players()) do local q=player:get_pos() if valid(q) and distance(p,q)<=RADIUS*RADIUS then close=true if nearby(q,RADIUS)>=PLAYER then return false end end end 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) forget(e) -- release before remove/on_deactivate: each prey counts once e.object:remove() end local function spawn(p,kind) if not safe(p,kind) or not habitat(p) or not room(p,kind) then return false end local obj=api.add_entity(p,"hearth:"..kind) local e=obj and obj:get_luaentity() return e and active[e] or false end local function callback_site(e) local p=e.object:get_pos() if not safe(p,e.kind) then return nil end local c=cell(at(p,0,0.1,0)) -- forage/deposit may mutate ANY cell of their 3x3 stencil. for x=-1,1 do for z=-1,1 do if not safe({x=c.x+x,y=c.y-0.5,z=c.z+z},e.kind) then return nil end end end return c end local function move(e,dx,dz) local a=e.object:get_pos() local len=math.sqrt(dx*dx+dz*dz) if len<0.001 then return false end local stride=e.kind=="fox" and 1.2 or 0.9 local b=at(a,dx/len*stride,0,dz/len*stride) if not path(a,b,e.kind) then return false end if nearby(b,16,nil,e)>=LOCAL then return false end for _, player in ipairs(api.get_connected_players()) do local p=player:get_pos() if valid(p) and distance(b,p)<=RADIUS*RADIUS and nearby(p,RADIUS,nil,e)>=PLAYER then return false end end e.object:set_velocity({x=0,y=0,z=0}) e.object:set_pos(b) -- short swept ground stride; gravity remains enabled local atan2=math.atan2 or function(y,x) return math.atan(y,x) end e.object:set_yaw(atan2(-dx,dz)) -- authored forward +Z return true end local function threat(e,p) local closest,best=nil,64 for _, player in ipairs(api.get_connected_players()) do local q=player:get_pos() if valid(q) and distance(p,q)35 or not habitat(p) then return false end for mate in pairs(active) do local q=mate.object:get_pos() if mate~=e and mate.kind==e.kind and mate.age>=60 and mate.food>=needed and mate.hunger<=35 and clock>=mate.breed_at and valid(q) and path(p,q,e.kind) then for _, d in ipairs(directions) do local b=at(p,d[1]*0.8,0,d[2]*0.8) if path(p,b,e.kind) and spawn(b,e.kind) then e.food=e.food-needed; mate.food=mate.food-needed e.breed_at=clock+90; mate.breed_at=clock+90 births=births+1; return true end end return false end end return false end local function think(e) local p=e.object:get_pos() if not safe(p,e.kind) then remove(e); return end local danger=threat(e,p) if danger then if not move(e,p.x-danger.x,p.z-danger.z) then move(e,-(p.z-danger.z),p.x-danger.x) end return end local site=callback_site(e) if e.dung_at and clock>=e.dung_at and site then local s=hearth.succession if s and s.deposit and s.deposit(site) then dung=dung+1 end e.dung_at=nil -- one attempt per meal, never a growing queue return end if breed(e,p) then return end if e.kind=="fox" then local prey,best=nil,144 for other in pairs(active) do local q=other.object:get_pos() if other.kind=="hare" and valid(q) and distance(p,q)=15 then local q=prey.object:get_pos() if best<=1.44 and clock>=e.feed_at and path(p,q,"fox") and safe(q,"hare") then remove(prey); meals=meals+1; e.food=math.min(6,e.food+1) e.hunger=math.max(0,e.hunger-35); e.feed_at=clock+20; e.dung_at=clock+8 else move(e,q.x-p.x,q.z-p.z) end return end elseif e.hunger>=15 and clock>=e.feed_at and site and habitat(p) then local edible=false for x=-1,1 do for z=-1,1 do local q=at(site,x,0,z) if natural(q,node(q)) then edible=true end end end local s=hearth.succession if edible and s and s.forage and s.forage(site) then e.food=math.min(6,e.food+1); e.hunger=math.max(0,e.hunger-30) e.feed_at=clock+12; e.dung_at=clock+8; grazed=grazed+1; return end end for i=1,3 do local d=directions[(e.id+e.turn+i)%#directions+1] local q=at(p,d[1]*0.9,0,d[2]*0.9) if habitat(q) and move(e,d[1],d[2]) then e.turn=e.turn+1; return end end e.turn=e.turn+1 end for _, kind in ipairs(kinds) do local s=species[kind] api.register_entity("hearth:"..kind, { initial_properties={physical=true,collide_with_objects=true, collisionbox={-s.radius,0,-s.length,s.radius,s.body,s.length}, visual="mesh",mesh="hearth_"..kind..".obj",textures={"hearth_"..kind..".png"}, visual_size={x=1,y=1},static_save=false,pointable=false,makes_footstep_sound=false}, on_activate=function(self,staticdata,dtime_s) if active[self] then return end local p=self.object:get_pos() -- add_entity may activate synchronously; enforce caps centrally here. if (staticdata and staticdata~="") or (dtime_s and dtime_s~=0) or not safe(p,kind) or not habitat(p) or not room(p,kind) then self.object:remove(); return end serial=serial+1; self.id=serial; self.kind=kind; self.age=0; self.turn=0 self.hunger=20; self.food=0; self.feed_at=clock+8; self.breed_at=clock+60 self.next_ai=clock+2; active[self]=true; total=total+1; counts[kind]=counts[kind]+1 self.object:set_armor_groups({immortal=1}) self.object:set_acceleration({x=0,y=-9.81,z=0}) self.object:set_velocity({x=0,y=0,z=0}) end, on_step=function(self,dt) if not active[self] then return end local t=delta(dt) self.age=self.age+t; self.hunger=math.min(100,self.hunger+t*0.6) if self.age>=900 or self.hunger>=100 then remove(self); return end local p=self.object:get_pos() if not valid(p) or not permitted(p) or not node(cell(p)) then remove(self) end end, on_punch=function() return true end, on_deactivate=forget, }) end api.register_globalstep(function(dt) local t=delta(dt) clock=clock+t; credit=math.min(BUDGET,credit+t*BUDGET); recruitment=recruitment+t local list={} for e in pairs(active) do list[#list+1]=e end table.sort(list,function(a,b) return a.id=e.next_ai then credit=credit-1; actions=actions+1; e.next_ai=clock+2; think(e) end end if recruitment<30 or credit<1 then return end recruitment=0; credit=credit-1; actions=actions+1 local players=api.get_connected_players() if #players==0 then return end player_cursor=player_cursor%#players+1; recruit_kind=recruit_kind%#kinds+1 local center=players[player_cursor]:get_pos() if not valid(center) then return end local kind=kinds[recruit_kind] if kind=="fox" and nearby(center,RADIUS,"hare")<2 then return end for _, d in ipairs(offsets) do for y=2,-3,-1 do local p={x=math.floor(center.x+d[1]+0.5),y=math.floor(center.y+0.5)+y-0.5,z=math.floor(center.z+d[2]+0.5)} if spawn(p,kind) then return end end end end) return {species="hearth:hare",global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL,ai_budget=BUDGET, status=function() return {total=total,hare=counts.hare,deer=counts.deer,fox=counts.fox, global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL, actions=actions,births=births,meals=meals,grazed=grazed,dung=dung} end} end