1003 lines
49 KiB
Lua
Executable File
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
|