This commit is contained in:
2026-11-05 09:57:11 -05:00
parent d34388bf2c
commit 89e392cc2e
81 changed files with 12410 additions and 1135 deletions
+11
View File
@@ -0,0 +1,11 @@
on: [push, pull_request]
name: build
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: lint
uses: Roang-zero1/factorio-mod-luacheck@master
with:
luacheckrc_url: ""
+15
View File
@@ -0,0 +1,15 @@
max_line_length = 120
globals = {
"minetest",
"VoxelArea",
"creatura",
}
read_globals = {
"vector",
"ItemStack",
table = {fields = {"copy"}}
}
ignore = {"212/self", "212/this"}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 ElCeejo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13
View File
@@ -0,0 +1,13 @@
# Bundled third-party dependency
This directory contains Creatura 0.2.10 from Luanti ContentDB release 34130.
It is bundled as the wildlife AI/pathfinding/flocking API dependency for Last Hearth.
- ContentDB: https://content.luanti.org/packages/ElCeejo/creatura/
- Upstream source: https://github.com/ElCeejo/creatura/tree/4eb507cf2433f0787691f560842deea79a1666f4
- License: MIT; original LICENSE is preserved alongside this notice.
- ContentDB archive: https://cdn.content.luanti.org/uploads/73b01eb441.zip
- Archive SHA-256: `f384a34e0ed6e03c52b6d6591566e6546a26776c5f7e6e7dce279e7993a4251e`
The archive hash was verified before extraction. No Animalia code, models,
textures, or sound files are included by this dependency.
+557
View File
@@ -0,0 +1,557 @@
--------------
-- Creatura --
--------------
creatura.api = {}
-- Math --
local abs = math.abs
local floor = math.floor
local random = math.random
local function clamp(val, min_n, max_n)
if val < min_n then
val = min_n
elseif max_n < val then
val = max_n
end
return val
end
local vec_dist = vector.distance
local function vec_raise(v, n)
if not v then return end
return {x = v.x, y = v.y + n, z = v.z}
end
---------------
-- Local API --
---------------
local function contains_val(tbl, val)
for _, v in pairs(tbl) do
if v == val then return true end
end
return false
end
----------------------------
-- Registration Functions --
----------------------------
creatura.registered_movement_methods = {}
function creatura.register_movement_method(name, func)
creatura.registered_movement_methods[name] = func
end
creatura.registered_utilities = {}
function creatura.register_utility(name, func)
creatura.registered_utilities[name] = func
end
---------------
-- Utilities --
---------------
function creatura.is_valid(mob)
if not mob then return false end
if type(mob) == "table" then mob = mob.object end
if type(mob) == "userdata" then
if mob:is_player() then
if mob:get_look_horizontal() then return mob end
else
if mob:get_yaw() then return mob end
end
end
return false
end
function creatura.is_alive(mob)
if not creatura.is_valid(mob) then
return false
end
if type(mob) == "table" then
return (mob.hp or mob.health or 0) > 0
end
if mob:is_player() then
return mob:get_hp() > 0
else
local ent = mob:get_luaentity()
return ent and (ent.hp or ent.health or 0) > 0
end
end
------------------------
-- Environment access --
------------------------
local default_node_def = {walkable = true} -- both ignore and unknown nodes are walkable
function creatura.get_node_height_from_def(name)
local def = minetest.registered_nodes[name] or default_node_def
if not def then return 0.5 end
if def.walkable then
if def.drawtype == "nodebox" then
if def.node_box
and def.node_box.type == "fixed" then
if type(def.node_box.fixed[1]) == "number" then
return 0.5 + def.node_box.fixed[5]
elseif type(def.node_box.fixed[1]) == "table" then
return 0.5 + def.node_box.fixed[1][5]
else
return 1
end
else
return 1
end
else
return 1
end
else
return 1
end
end
local get_node = minetest.get_node
function creatura.get_node_def(node) -- Node can be name or pos
if type(node) == "table" then
node = get_node(node).name
end
local def = minetest.registered_nodes[node] or default_node_def
if def.walkable
and creatura.get_node_height_from_def(node) < 0.26 then
def.walkable = false -- workaround for nodes like snow
end
return def
end
local get_node_def = creatura.get_node_def
function creatura.get_ground_level(pos, range)
range = range or 2
local above = vector.round(pos)
local under = {x = above.x, y = above.y - 1, z = above.z}
if not get_node_def(above).walkable and get_node_def(under).walkable then return above end
if get_node_def(above).walkable then
for _ = 1, range do
under = above
above = {x = above.x, y = above.y + 1, z = above.z}
if not get_node_def(above).walkable and get_node_def(under).walkable then return above end
end
end
if not get_node_def(under).walkable then
for _ = 1, range do
above = under
under = {x = under.x, y = under.y - 1, z = under.z}
if not get_node_def(above).walkable and get_node_def(under).walkable then return above end
end
end
return above
end
function creatura.is_pos_moveable(pos, width, height)
local edge1 = {
x = pos.x - (width + 0.2),
y = pos.y,
z = pos.z - (width + 0.2),
}
local edge2 = {
x = pos.x + (width + 0.2),
y = pos.y,
z = pos.z + (width + 0.2),
}
local base_p = {x = pos.x, y = pos.y, z = pos.z}
local top_p = {x = pos.x, y = pos.y + height, z = pos.z}
for z = edge1.z, edge2.z do
for x = edge1.x, edge2.x do
base_p.x, base_p.z = pos.x + x, pos.z + z
top_p.x, top_p.z = pos.x + x, pos.z + z
local ray = minetest.raycast(base_p, top_p, false, false)
for pointed_thing in ray do
if pointed_thing.type == "node" then
local name = get_node(pointed_thing.under).name
if creatura.get_node_def(name).walkable then
return false
end
end
end
end
end
return true
end
local function is_blocked_thin(pos, height)
local node
local pos2 = {
x = floor(pos.x + 0.5),
y = floor(pos.y + 0.5) - 1,
z = floor(pos.z + 0.5)
}
for _ = 1, height do
pos2.y = pos2.y + 1
node = minetest.get_node_or_nil(pos2)
if not node
or get_node_def(node.name).walkable then
return true
end
end
return false
end
function creatura.is_blocked(pos, width, height)
if width <= 0.5 then
return is_blocked_thin(pos, height)
end
local p1 = {
x = pos.x - (width + 0.2),
y = pos.y,
z = pos.z - (width + 0.2),
}
local p2 = {
x = pos.x + (width + 0.2),
y = pos.y + (height + 0.2),
z = pos.z + (width + 0.2),
}
local node
local pos2 = {}
for z = p1.z, p2.z do
pos2.z = z
for y = p1.y, p2.y do
pos2.y = y
for x = p1.x, p2.x do
pos2.x = x
node = minetest.get_node_or_nil(pos2)
if not node
or get_node_def(node.name).walkable then
return true
end
end
end
end
return false
end
function creatura.fast_ray_sight(pos1, pos2, water)
local ray = minetest.raycast(pos1, pos2, false, water or false)
local pointed_thing = ray:next()
while pointed_thing do
if pointed_thing.type == "node"
and creatura.get_node_def(pointed_thing.under).walkable then
return false, vec_dist(pos1, pointed_thing.intersection_point), pointed_thing.ref, pointed_thing.intersection_point
end
pointed_thing = ray:next()
end
return true, vec_dist(pos1, pos2), false, pos2
end
local fast_ray_sight = creatura.fast_ray_sight
function creatura.sensor_floor(self, range, water)
local pos = self.object:get_pos()
local pos2 = vec_raise(pos, -range)
local _, dist, node = fast_ray_sight(pos, pos2, water or false)
return dist, node
end
function creatura.sensor_ceil(self, range, water)
local pos = vec_raise(self.object:get_pos(), self.height)
local pos2 = vec_raise(pos, range)
local _, dist, node = fast_ray_sight(pos, pos2, water or false)
return dist, node
end
function creatura.get_nearby_player(self, range)
local pos = self.object:get_pos()
if not pos then return end
local stored = self._nearby_obj or {}
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
for _, object in ipairs(objects) do
if object:is_player()
and creatura.is_alive(object) then
return object
end
end
end
function creatura.get_nearby_players(self, range)
local pos = self.object:get_pos()
if not pos then return end
local stored = self._nearby_obj or {}
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
local nearby = {}
for _, object in ipairs(objects) do
if object:is_player()
and creatura.is_alive(object) then
table.insert(nearby, object)
end
end
return nearby
end
function creatura.get_nearby_object(self, name, range)
local pos = self.object:get_pos()
if not pos then return end
local stored = self._nearby_obj or {}
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
for _, object in ipairs(objects) do
local ent = creatura.is_alive(object) and object:get_luaentity()
if ent
and object ~= self.object
and not ent._ignore
and ((type(name) == "table" and contains_val(name, ent.name))
or ent.name == name) then
return object
end
end
end
function creatura.get_nearby_objects(self, name, range)
local pos = self.object:get_pos()
if not pos then return end
local stored = self._nearby_obj or {}
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
local nearby = {}
for _, object in ipairs(objects) do
local ent = creatura.is_alive(object) and object:get_luaentity()
if ent
and object ~= self.object
and not ent._ignore
and ((type(name) == "table" and contains_val(name, ent.name))
or ent.name == name) then
table.insert(nearby, object)
end
end
return nearby
end
creatura.get_nearby_entity = creatura.get_nearby_object
creatura.get_nearby_entities = creatura.get_nearby_objects
--------------------
-- Global Mob API --
--------------------
function creatura.default_water_physics(self)
local pos = self.stand_pos
local stand_node = self.stand_node
if not pos or not stand_node then return end
local gravity = self._movement_data.gravity or -9.8
local submergence = self.liquid_submergence or 0.25
local drag = self.liquid_drag or 0.7
if minetest.get_item_group(stand_node.name, "liquid") > 0 then -- In Liquid
local vel = self.object:get_velocity()
if not vel then return end
self.in_liquid = stand_node.name
if submergence < 1 then
local mob_level = pos.y + (self.height * submergence)
-- Find Water Surface
local nodes = minetest.find_nodes_in_area_under_air(
{x = pos.x, y = pos.y, z = pos.z},
{x = pos.x, y = pos.y + 3, z = pos.z},
"group:liquid"
) or {}
local surface_level = (#nodes > 0 and nodes[#nodes].y or pos.y + self.height + 3)
surface_level = floor(surface_level + 0.9)
local height_diff = mob_level - surface_level
-- Apply Bouyancy
if height_diff <= 0 then
local displacement = clamp(abs(height_diff) / submergence, 0.5, 1) * self.width
self.object:set_acceleration({x = 0, y = displacement, z = 0})
else
self.object:set_acceleration({x = 0, y = gravity, z = 0})
end
end
-- Apply Drag
self.object:set_velocity({
x = vel.x * (1 - self.dtime * drag),
y = vel.y * (1 - self.dtime * drag),
z = vel.z * (1 - self.dtime * drag)
})
else
self.in_liquid = nil
self.object:set_acceleration({x = 0, y = gravity, z = 0})
end
end
function creatura.default_vitals(self)
local pos = self.object:get_pos()
local node = minetest.get_node_or_nil(pos)
if not pos or not node then return end
local max_fall = self.max_fall or 3
local in_liquid = self.in_liquid
local on_ground = self.touching_ground
local damage = 0
-- Fall Damage
if max_fall > 0
and not in_liquid then
local fall_start = self._fall_start or (not on_ground and pos.y)
if fall_start
and on_ground then
damage = floor(fall_start - pos.y)
if damage < max_fall then
damage = 0
else
local resist = self.fall_resistance or 0
damage = damage - damage * resist
end
fall_start = nil
end
self._fall_start = fall_start
end
-- Environment Damage
if self:timer(1) then
local stand_def = creatura.get_node_def(node.name)
local max_breath = self.max_breath or 0
-- Suffocation
if max_breath > 0 then
local head_pos = {x = pos.x, y = pos.y + self.height, z = pos.z}
local head_def = creatura.get_node_def(head_pos)
if head_def.groups
and (minetest.get_item_group(head_def.name, "water") > 0
or (head_def.walkable
and head_def.groups.disable_suffocation ~= 1
and head_def.drawtype == "normal")) then
local breath = self._breath
if breath <= 0 then
damage = damage + 1
else
self._breath = breath - 1
self:memorize("_breath", breath)
end
end
end
-- Burning
local fire_resist = self.fire_resistance or 0
if fire_resist < 1
and minetest.get_item_group(stand_def.name, "igniter") > 0
and stand_def.damage_per_second then
local fire_damage = stand_def.damage_per_second - (stand_def.damage_per_second * fire_resist)
damage = (damage or 0) + fire_damage
end
end
-- Apply Damage
if damage > 0 then
self:hurt(damage)
self:indicate_damage()
if random(4) < 2 then
self:play_sound("hurt")
end
end
-- Entity Cramming
if self:timer(5) then
local objects = minetest.get_objects_inside_radius(pos, 0.2)
if #objects > 10 then
self:indicate_damage()
self.hp = self:memorize("hp", -1)
self:death_func()
end
end
end
function creatura.drop_items(self)
if not self.drops then return end
local pos = self.object:get_pos()
if not pos then return end
local drop_def, item_name, min_items, max_items, chance, amount, drop_pos
for i = 1, #self.drops do
drop_def = self.drops[i]
item_name = drop_def.name
if not item_name then return end
chance = drop_def.chance or 1
if random(chance) < 2 then
min_items = drop_def.min or 1
max_items = drop_def.max or 2
amount = random(min_items, max_items)
drop_pos = {
x = pos.x + random(-5, 5) * 0.1,
y = pos.y,
z = pos.z + random(-5, 5) * 0.1
}
local item = minetest.add_item(drop_pos, ItemStack(item_name .. " " .. amount))
if item then
item:add_velocity({
x = random(-2, 2),
y = 1.5,
z = random(-2, 2)
})
end
end
end
end
function creatura.basic_punch_func(self, puncher, tflp, tool_caps, dir)
if not puncher then return end
local tool
local tool_name = ""
local add_wear = false
if puncher:is_player() then
tool = puncher:get_wielded_item()
tool_name = tool:get_name()
add_wear = not minetest.is_creative_enabled(puncher:get_player_name())
end
if (self.immune_to
and contains_val(self.immune_to, tool_name)) then
return
end
local damage = 0
local armor_grps = self.object:get_armor_groups() or self.armor_groups or {}
for group, val in pairs(tool_caps.damage_groups or {}) do
local dmg_x = tflp / (tool_caps.full_punch_interval or 1.4)
damage = damage + val * clamp(dmg_x, 0, 1) * ((armor_grps[group] or 0) / 100.0)
end
if damage > 0 then
local dist = vec_dist(self.object:get_pos(), puncher:get_pos())
dir.y = 0.2
if self.touching_ground then
local power = clamp((damage / dist) * 8, 0, 8)
self:apply_knockback(dir, power)
end
self:hurt(damage)
end
if add_wear then
local wear = floor((tool_caps.full_punch_interval / 75) * 9000)
tool:add_wear(wear)
puncher:set_wielded_item(tool)
end
if (tflp or 0) > 0.5 then
self:play_sound("hit")
if random(2) < 2 then
self:play_sound("hurt")
end
end
self:indicate_damage()
end
local path = minetest.get_modpath("creatura")
dofile(path.."/mob_meta.lua")
+232
View File
@@ -0,0 +1,232 @@
-----------
-- Boids --
-----------
local abs = math.abs
local atan2 = math.atan2
local sin = math.sin
local cos = math.cos
local function average_angle(tbl)
local sum_sin, sum_cos = 0, 0
for _, v in pairs(tbl) do
sum_sin = sum_sin + sin(v)
sum_cos = sum_cos + cos(v)
end
return atan2(sum_sin, sum_cos)
end
local function average(tbl)
local sum = 0
for _,v in pairs(tbl) do -- Get the sum of all numbers in t
sum = sum + v
end
return sum / #tbl
end
local function interp_rad(a, b, w)
local cs = (1 - w) * cos(a) + w * cos(b)
local sn = (1 - w) * sin(a) + w * sin(b)
return atan2(sn, cs)
end
local vec_add = vector.add
local vec_dir = vector.direction
local vec_dist = vector.distance
local vec_divide = vector.divide
local vec_normal = vector.normalize
local function get_average_pos(vectors)
local sum = {x = 0, y = 0, z = 0}
for _, vec in pairs(vectors) do sum = vec_add(sum, vec) end
return vec_divide(sum, #vectors)
end
local function dist_2d(pos1, pos2)
local a = vector.new(
pos1.x,
0,
pos1.z
)
local b = vector.new(
pos2.x,
0,
pos2.z
)
return vec_dist(a, b)
end
local yaw2dir = minetest.yaw_to_dir
local dir2yaw = minetest.dir_to_yaw
-- Get Boid Members --
function creatura.get_boid_cached(self)
local pos = self.object:get_pos()
if not pos then return end
local radius = self.tracking_range * 0.5 or 4
local members = self._movement_data.boids or {}
local max_boids = self.max_boids or 7
if #members > 0 then
for i = #members, 1, -1 do
local object = members[i]
if not object or not object:get_yaw() then members[i] = nil end
end
if #members >= max_boids then return members end
end
local objects = minetest.get_objects_inside_radius(pos, radius)
if #objects < 2 then return {} end
for _, object in ipairs(objects) do
local ent = object and object ~= self.object and object:get_luaentity()
if ent
and ent.name == self.name then
local move_data = ent._movement_data
if move_data
and (not move_data.boids
or #move_data.boids < max_boids) then
table.insert(members, object)
end
end
if #members >= max_boids then break end
end
self._movement_data.boids = members
return members
end
-- Calculate Boid Movement Direction
function creatura.get_boid_dir(self)
local pos = self.object:get_pos()
if not pos then return end
local boids = creatura.get_boid_cached(self)
if #boids < 2 then return end
local pos_no, pos_sum = 0, {x = 0, y = 0, z = 0}
local sum_sin, sum_cos = 0, 0
local lift_no, lift_sum = 0, 0
local vel
local boid_pos
local closest_pos
for _, object in ipairs(boids) do
if object then
boid_pos, vel = object:get_pos(), object:get_velocity()
if boid_pos then
vel = vec_normal(vel)
local obj_yaw = object:get_yaw()
pos_no, pos_sum = pos_no + 1, vec_add(pos_sum, boid_pos)
sum_sin, sum_cos = sum_sin + sin(obj_yaw), sum_cos + cos(obj_yaw)
lift_no, lift_sum = lift_no + 1, lift_sum + vel.y
if not closest_pos
or vec_dist(pos, boid_pos) < vec_dist(pos, closest_pos) then
closest_pos = boid_pos
end
end
end
end
if not closest_pos then return end
local center = vec_divide(pos_sum, pos_no)
local lift = lift_sum / lift_no
local angle_sin, angle_cos
local radius = self.tracking_range * 0.5 or 4
local dist_factor = (radius - vec_dist(pos, closest_pos)) / radius
local alignment = atan2(sum_sin, sum_cos)
local seperation = dir2yaw(vec_dir(closest_pos, pos))
local cohesion = dir2yaw(vec_dir(pos, center))
if dist_factor > 0.9 then
seperation = interp_rad(alignment, seperation, 0.5)
angle_sin, angle_cos = sin(seperation), cos(seperation)
else
angle_sin, angle_cos = sin(cohesion), cos(cohesion)
end
local angle = atan2(angle_sin + sin(alignment), angle_cos + cos(alignment))
local dir = yaw2dir(angle)
dir.y = lift
return vector.normalize(dir), boids
end
-- Deprecated
function creatura.get_boid_members(pos, radius, name)
local objects = minetest.get_objects_inside_radius(pos, radius)
if #objects < 2 then return {} end
local members = {}
local max_boid = minetest.registered_entities[name].max_boids or 7
for i = 1, #objects do
if #members > max_boid then break end
local object = objects[i]
if object:get_luaentity()
and object:get_luaentity().name == name then
table.insert(members, object)
end
end
if #members > 1 then
for _, object in ipairs(members) do
local ent = object and object:get_luaentity()
if ent then
ent._movement_data.boids = members
end
end
end
return members
end
function creatura.get_boid_angle(self, _boids, range)
local pos = self.object:get_pos()
local boids = _boids or creatura.get_boid_members(pos, range or 4, self.name)
if #boids < 3 then return end
local yaw = self.object:get_yaw()
local lift = self.object:get_velocity().y
-- Add Boid data to tables
local closest_pos
local positions = {}
local angles = {}
local lifts = {}
for i = 1, #boids do
local boid = boids[i]
if boid:get_pos() then
local vel = boid:get_velocity()
if boid ~= self.object
and (abs(vel.x) > 0.1
or abs(vel.z) > 0.1) then
local boid_pos = boid:get_pos()
table.insert(positions, boid_pos)
table.insert(lifts, vec_normal(vel).y)
table.insert(angles, boid:get_yaw())
if not closest_pos
or vec_dist(pos, boid_pos) < vec_dist(pos, closest_pos) then
closest_pos = boid_pos
end
end
end
end
if #positions < 3 then return end
local center = get_average_pos(positions)
local dir2closest = vec_dir(pos, closest_pos)
-- Calculate Parameters
local alignment = average_angle(angles)
center = vec_add(center, yaw2dir(alignment))
local dir2center = vec_dir(pos, center)
local seperation = yaw + -(dir2yaw(dir2closest) - yaw)
local cohesion = dir2yaw(dir2center)
local params = {alignment}
if dist_2d(pos, closest_pos) < (self.boid_seperation or self.width * 3) then
table.insert(params, seperation)
elseif dist_2d(pos, center) > (#boids * 0.33) * (self.boid_seperation or self.width * 3) then
table.insert(params, cohesion)
end
-- Vertical Params
local vert_alignment = average(lifts)
local vert_seperation = (self.speed or 2) * -dir2closest.y
local vert_cohesion = (self.speed or 2) * dir2center.y
local vert_params = {vert_alignment}
if math.abs(pos.y - closest_pos.y) < (self.boid_seperation or self.width * 3) then
table.insert(vert_params, vert_seperation)
elseif math.abs(pos.y - closest_pos.y) > 1.5 * (self.boid_seperation or self.width * 3) then
table.insert(vert_params, vert_cohesion + (lift - vert_cohesion) * 0.1)
end
return average_angle(params), average(vert_params)
end
+280
View File
@@ -0,0 +1,280 @@
Registration
------------
creatura.register_mob(name, mob definition)
Mob Definition uses almost all entity definition params
{
max_health = 10 -- Maximum Health
damage = 0 -- Damage dealt by mob
speed = 4 -- Maximum Speed
tracking_range = 16 -- Maximum range for finding entities/blocks
despawn_after = 1500 -- Despawn after being active for this amount of time
max_fall = 8 -- How far a mob can fall before taking damage (set to 0 to disable fall damage)
turn_rate = 7 -- Turn Rate in rad/s
bouyancy_multiplier = 1 -- Multiplier for bouyancy effects (set to 0 to disable bouyancy)
hydrodynamics_multiplier = 1 -- Multiplier for hydroynamic effects (set to 0 to disable hydrodynamics)
hitbox = { -- Hitbox params (Uses custom registration to force get_pos() to always return bottom of box)
width = 0.5, (total width = width * 2. A width of 0.5 results in a box with a total width of 1)
height = 1 (total height of box)
}
animations = {
anim = {range = {x = 1, y = 10}, speed = 30, frame_blend = 0.3, loop = true}
}
drops = {
{name = (itemstring), min = 1, max = 3, chance = 1},
}
follow = {
"farming:seed_wheat",
"farming:seed_cotton"
}
utility_stack = {
-- Every second, all utilities in the stack are evaluated
-- Whichever utilitiy's get_score function returns the highest number will be executed
-- If multiple utilities have the same score, the one with the highest index is executed
[1] = {
`utility` -- name of utility to evaluate
`get_score` -- function (only accepts `self` as an arg) that returns a number
}
}
activate_func = function(self, staticdata, dtime_s) -- called upon activation
step_func = function(self, dtime, moveresult) -- called every server step
death_func = function(self) -- called when mobs health drops to/below 0
}
Lua Entity Methods
------------------
`move(pos, method, speed, animation)`
- `pos`: position to move to
- `method`: method used to move to `pos`
- `speed`: multiplier for `speed`
- `animation`: animation to play while moving
`halt()`
- stops movement
`turn_to(yaw[, turn_rate])`
- `yaw`: yaw (in radians) to turn to
- `turn_rate`: turn rate in rad/s (default: 10) -- likely to be deprecated
`set_gravity(gravity)`
- `gravity`: vertical acceleration rate
`set_forward_velocity(speed)`
- `speed`: rate in m/s to travel forward at
`set_vertical_velocity(speed)`
- `speed`: rate in m/s to travel vertically at
`apply_knockback(dir, power)`
- `dir`: direction vector
- `power`: multiplier for dir
`punch_target(target)`
- applies 'damage' to 'target'
`hurt(damage)`
- `damage`: number to subtract from health (ignores armor)
`heal(health)`
- `health`: number to add to health
`get_center_pos()`
- returns position at center of hitbox
`pos_in_box(pos[, size])`
- returns true if 'pos' is within hitbox
- `size`: width of box to check in (optional)
`animate(anim)`
- sets animation to `anim`
`set_texture(id, tbl)`
- `id`: table index
- `tbl`: table of textures
`set_scale(x)`
- `x`: multiplier for base scale (0.5 sets scale to half, 2 sets scale to double)
`fix_attached_scale(parent)`
- sets scale to appropriate value when attached to 'parent'
- `parent`: object
`memorize(id, val)`
-- stores `val` to staticdata
- `id`: key for table
- `val`: value to store
`forget(id)`
-- removes `id` from staticdata
`recall(id)`
-- returns value of `id` from staticdata
`timer(n)`
-- returns true avery `n` seconds
`get_hitbox()`
-- returns current hitbox
`get_height()`
-- returns current height
`get_visual_size()`
-- returns current visual size
`follow_wielded_item(player)`
-- returns itemstack, item name of `player`s wielded item if item is in 'follow'
`get_target(target)`
-- returns if `target` is alive, if mob has a line of sight with `target`, position of `target`
Utilities
---------
* `creatura.is_valid(mob)`
* Returns false if object doesn't exist, otherwise returns ObjectRef/PlayerRef
* `mob`: Luaentity, ObjectRef, or PlayerRef
* `creatura.is_alive(mob)`
* Returns false if object doesn't exist or is dead, otherwise returns ObjectRef/PlayerRef
* `mob`: Luaentity, ObjectRef, or PlayerRef
Environment access
------------------
* `creatura.get_node_height_from_def(name)`
-- Returns total height of nodebox
-- `name`: Itemstring/Name of node
* `creatura.get_node_def(node)`
-- Returns definition of node
-- `node`: Itemstring/Name of node or position
* `creatura.get_ground_level(pos, max_diff)`
* Returns first position above walkable node within `max_diff`
* `creatura.is_pos_moveable(pos, width, height)`
* Returns true if a box with specified `width` and `height` can fit at `pos`
* `width` should be the largest side of the collision box
* Check from bottom of box
* `creatura.fast_ray_sight(pos1, pos2, water)`
* Checks for line of sight between `pos1 and `pos2`
* Returns bool
* Returns distance to obstruction
* `creatura.sensor_floor(self, range, water)`
* Finds distance to ground from bottom of entities hitbox
* Returns distance to ground or `range` if no ground is found
* `range`: Maximum range
* `water`: If false, water will not be counted as ground
* `creatura.sensor_ceil(self, range, water)`
* Finds distance to ceiling from top of entities hitbox
* Returns distance to ceiling or `range` if no ceiling is found
* `range`: Maximum range
* `water`: If false, water will not be counted as ceiling
* `creatura.get_nearby_player(self)`
* Finds player within `self.tracking_range`
* Returns PlayerRef or nil
* `creatura.get_nearby_players(self)`
* Finds players within `self.tracking_range`
* Returns table of PlayerRefs or empty table
* `creatura.get_nearby_object(self, name)`
* Finds object within `self.tracking_range`
* Returns ObjectRef or nil
* `name`: Name of object to search for
* `creatura.get_nearby_objects(self, name)`
* Finds objects within `self.tracking_range`
* Returns table of ObjectRefs or empty table
* `name`: Name of object to search for
Global Mob API
--------------
* `creatura.default_water_physics(self)`
* Bouyancy and Drag physics used by default on all Mobs
* `creatura.default_vitals(self)`
* Vitals used by default on all Mobs
* Handles suffocation, drowning, fire damage, and fall damage
* `creatura.drop_items(self)`
* Drops items from `self.drops`
* `creatura.basic_punch_func(self, puncher, time_from_last_punch, tool_capabilities, direction, damage)`
* Deals damage
* Applies knockback
* Visualy and audibly indicates damage
Pathfinding
-----------
Creatura's pathfinder uses the A* algorithm for speed, as well as Theta* for decent performance and more natural looking paths.
Both pathfinders will carry out pathfinding over multiple server steps to reduce lag spikes which does result in the path not
being returned immediately, so your code will have to account for this.
The maximum amount of time the pathfinder can spend per-step (in microseconds) can be adjusted in settings.
* `creatura.pathfinder.find_path(self, pos1, pos2, get_neighbors)`
* Finds a path from `pos1` to `pos2`
* `get_neighbors` is a function used to find valid neighbors
* `creatura.pathfinder.get_neighbors_fly` and `creatura.pathfinder.get_neighbors_swim` are bundled by default
* `creatura.pathfinder.find_path_theta(self, pos1, pos2, get_neighbors)`
* Finds a path from `pos1` to `pos2`
* Returns a path with arbitrary angles for natural looking paths at the expense of performance
* `get_neighbors` is a function used to find valid neighbors
* `creatura.pathfinder.get_neighbors_fly` and `creatura.pathfinder.get_neighbors_swim` are bundled by default
Spawning
--------
NOTE: Globalstep spawning from early versions of the API likely won't recieve much/any support going forward. Use ABM Spawning instead.
* `creatura.register_abm_spawn(name, def)`
* `name` of the mob to spawn
* `def` is a table of spawn parameters
* `chance` is the chance of a mob spawning every `interval`
* (a `chance` of 30 and `interval` of 60 would mean a 1 in 30 chance of a mob spawning every 60 seconds)
* `chance_on_load` same as `chance` but for LBM spawning (when a chunk is loaded for the first time)
* `interval` is how often (in seconds) a spawn attempt will happen
* `min_height` is the minimum height that a spawn attempt can happen at
* a `min_height` of 0 would mean the mob cannot spawn below a y coordinate of 0
* `max_height` is the maximum height that a spawn attempt can happen at
* a `max_height` of 128 would mean the mob cannot spawn above a y coordinate of 128
* `min_time` is the minimum time a mob can spawn at
* `max_time` is the maximum time a mob can spawn at
* set `min_time` to 19500 and `max_time` to 4500 to only spawn at night and swap the numbers to only spawn at day
* `min_light` is the minimum light level a mob can spawn at
* `max_light` is the maximum light level a mob can spawn at
* `min_group` is the lowest number of mobs to spawn in a group at a time
* value of 3 means the mob will always spawn with at least 3 mobs together
* `max_group` is the highest number of mobs to spawn in a group at a time
* `block_protected` will block spawning mobs in protected areas if set to true
* `biomes` is a table of biomes the mob can spawn in
* `nodes` is a table of nodes the mob can spawn in/on
* `neighbors` is a table of nodes that must be adjacent to the spawn position
* ex: set to `{"groups:tree"}` to force the mob to spawn next to a tree
* `spawn_on_load` will spawn mobs when a chunk generates if set to true
* `spawn_in_nodes` will spawn mobs inside the node rather than above if set to true
* set this to true for mobs that spawn in water
* `spawn_cap` is the maximum amount of the mob that can spawn within active block range
+25
View File
@@ -0,0 +1,25 @@
creatura = {}
local path = minetest.get_modpath("creatura")
dofile(path.."/api.lua")
dofile(path.."/pathfinding.lua")
dofile(path.."/pathfinder_deprecated.lua")
dofile(path.."/methods.lua")
-- Optional Files --
-- Optional files can be safely removed
-- by game developers who don't need the
-- extra features
local function load_file(filepath, filename)
if io.open(filepath .. "/" .. filename, "r") then
dofile(filepath .. "/" .. filename)
else
minetest.log("action", "[Creatura] The file " .. filename .. " could not be loaded.")
end
end
load_file(path, "boids.lua")
load_file(path, "spawning.lua")
+677
View File
@@ -0,0 +1,677 @@
-------------
-- Methods --
-------------
local pi = math.pi
local abs = math.abs
local ceil = math.ceil
local max = math.max
local random = math.random
local atan2 = math.atan2
local sin = math.sin
local cos = math.cos
local function diff(a, b) -- Get difference between 2 angles
return atan2(sin(b - a), cos(b - a))
end
local function clamp(val, _min, _max)
if val < _min then
val = _min
elseif _max < val then
val = _max
end
return val
end
local vec_add = vector.add
local vec_normal = vector.normalize
local vec_len = vector.length
local vec_dist = vector.distance
local vec_dir = vector.direction
local vec_dot = vector.dot
local vec_multi = vector.multiply
local vec_sub = vector.subtract
local yaw2dir = minetest.yaw_to_dir
local dir2yaw = minetest.dir_to_yaw
--[[local function debugpart(pos, time, tex)
minetest.add_particle({
pos = pos,
texture = tex or "creatura_particle_red.png",
expirationtime = time or 0.1,
glow = 16,
size = 24
})
end]]
---------------------
-- Local Utilities --
---------------------
local get_node_def = creatura.get_node_def
--local get_node_height = creatura.get_node_height_from_def
function creatura.get_collision(self, dir, range)
local pos, yaw = self.object:get_pos(), self.object:get_yaw()
if not pos then return end
local width, height = self.width or 0.5, self.height or 1
dir = dir or yaw2dir(yaw)
pos.x = pos.x + dir.x * width
pos.z = pos.z + dir.z * width
local cos_yaw = cos(yaw)
local sin_yaw = sin(yaw)
local width_i = width / ceil(width)
local height_i = height / ceil(height)
local pos_x, pos_y, pos_z = pos.x, pos.y, pos.z
local dir_x, dir_y, dir_z = dir.x, dir.y, dir.z
local pos2 = {x = pos_x, y = pos_y, z = pos_z}
local collision
pos.y = pos.y + height * 0.5
range = range or 4
local low_score
for _ = 0, range do
if collision then return collision end
pos_x = pos_x + dir_x
pos_y = pos_y + dir_y
pos_z = pos_z + dir_z
pos2.y = pos_y
for x = -width, width, width_i do
pos2.x = cos_yaw * ((pos_x + x) - pos_x) + pos_x
pos2.z = sin_yaw * ((pos_x + x) - pos_x) + pos_z
for y = height, 0, -height_i do
if y < self.stepheight or 1.1 then break end
pos2.y = pos_y + y
if get_node_def(pos2).walkable then
local score = abs(pos.y - pos2.y) * vec_dot(dir, vec_dir(pos, pos2))
if not low_score
or score < low_score then
low_score = score
collision = pos2
end
end
end
end
end
end
creatura.get_collision_ranged = creatura.get_collision
local get_collision = creatura.get_collision
local function get_avoidance_dir(self)
local pos = self.object:get_pos()
if not pos then return end
local _, col_pos = get_collision(self)
if col_pos then
local vel = self.object:get_velocity()
vel.y = 0
local vel_len = vec_len(vel) * (1 + (self.step_delay or 0))
local ahead = vec_add(pos, vec_normal(vel))
local avoidance_force = vec_sub(ahead, col_pos)
avoidance_force.y = 0
avoidance_force = vec_multi(vec_normal(avoidance_force), (vel_len > 1 and vel_len) or 1)
return vec_dir(pos, vec_add(ahead, avoidance_force))
end
end
local function get_collision_single(pos, water)
local pos2 = {x = pos.x, y = pos.y, z = pos.z}
local n_def = get_node_def(pos2)
if n_def.walkable
or (water and (n_def.groups.liquid or 0) > 0) then
pos2.y = pos.y + 1
n_def = get_node_def(pos2)
local col_max = n_def.walkable or (water and (n_def.groups.liquid or 0) > 0)
pos2.y = pos.y - 1
local col_min = col_max and (n_def.walkable or (water and (n_def.groups.liquid or 0) > 0))
if col_min then
return pos
else
pos2.y = pos.y + 1
return pos2
end
end
end
function creatura.get_avoidance_lift(self, pos2, range)
range = ceil(max(range or 1, 0.5))
local height_half = (self.height or 1) * 0.5
local center_y = pos2.y + height_half
local check_pos = {x = pos2.x, y = center_y, z = pos2.z}
-- Find ceiling and floor collisions
local def
local ceil_pos
local floor_pos
for i = 1, range, 0.5 do -- 0.5 increment increases accuracy
if ceil_pos and floor_pos then break end
check_pos.y = center_y + i
def = creatura.get_node_def(check_pos)
if not ceil_pos
and (def.walkable
or minetest.get_item_group(def.name, "liquid") > 0) then
ceil_pos = check_pos
end
check_pos.y = center_y - i
def = creatura.get_node_def(check_pos)
if not floor_pos
and (def.walkable
or minetest.get_item_group(def.name, "liquid") > 0) then
floor_pos = check_pos
end
end
-- Calculate direction to average point of collisions
check_pos.y = center_y
local offset = {x = 0, y = height_half + range, z = 0}
if not ceil_pos then ceil_pos = vec_add(check_pos, offset) end
if not floor_pos then floor_pos = vec_sub(check_pos, offset) end
local dist_up = ceil_pos.y - center_y
local dist_down = floor_pos.y - center_y
local altitude = (dist_up + dist_down) / 2
return ((check_pos.y + altitude) - center_y) / range * 2
end
function creatura.get_avoidance_lift_aquatic(self, pos2, range)
range = ceil(max(range or 1, 0.5))
local height_half = (self.height or 1) * 0.5
local center_y = pos2.y + height_half
local check_pos = {x = pos2.x, y = center_y, z = pos2.z}
-- Find ceiling and floor collisions
local ceil_pos
local floor_pos
for i = 1, range, 0.5 do -- 0.5 increment increases accuracy
if ceil_pos and floor_pos then break end
check_pos.y = center_y + i
if not ceil_pos
and minetest.get_item_group(creatura.get_node_def(check_pos).name, "liquid") < 1 then
ceil_pos = check_pos
end
check_pos.y = center_y - i
if not floor_pos
and minetest.get_item_group(creatura.get_node_def(check_pos).name, "liquid") < 1 then
floor_pos = check_pos
end
end
-- Calculate direction to average point of collisions
check_pos.y = center_y
local offset = {x = 0, y = height_half + range, z = 0}
if not ceil_pos then ceil_pos = vec_add(check_pos, offset) end
if not floor_pos then floor_pos = vec_sub(check_pos, offset) end
local dist_up = ceil_pos.y - center_y
local dist_down = floor_pos.y - center_y
local altitude = (dist_up + dist_down) / 2
return ((check_pos.y + altitude) - center_y) / range * 2
end
----------------------------
-- Context Based Steering --
----------------------------
local steer_directions = {
vec_normal({x = 1, y = 0, z = 0}),
vec_normal({x = 1, y = 0, z = 1}),
vec_normal({x = 0, y = 0, z = 1}),
vec_normal({x = -1, y = 0, z = 0}),
vec_normal({x = -1, y = 0, z = -1}),
vec_normal({x = 0, y = 0, z = -1}),
vec_normal({x = 1, y = 0, z = -1}),
vec_normal({x = -1, y = 0, z = 1})
}
-- Context Methods
function creatura.get_context_default(self, goal, steer_dir, interest, danger, range)
local pos = self.object:get_pos()
if not pos then return end
local width, height = self.width or 0.5, self.height or 1
local y_offset = math.min(self.stepheight or 1.1, height)
pos.y = pos.y + y_offset
local collision
local ray = minetest.raycast(pos, vec_add(pos, vec_multi(steer_dir, width + range)), false, false)
local pointed = ray:next()
if pointed
and pointed.type == "node"
and creatura.get_node_def(pointed.under).walkable then
collision = pointed.under
end
if collision then
local dir2goal = vec_normal(vec_dir(pos, goal))
local dir2col = vec_normal(vec_dir(pos, collision))
local dist2col = vec_dist(pos, collision) - width
local dot_score = vec_dot(dir2col, dir2goal)
local dist_score = (range - dist2col) / range
interest = interest - dot_score
danger = dist_score
end
return interest, danger
end
function creatura.get_context_large(self, goal, steer_dir, interest, danger, range)
local pos = self.object:get_pos()
if not pos then return end
local width, height = self.width or 0.5, self.height or 1
local y_offset = math.min(self.stepheight or height)
pos.y = pos.y + y_offset
local collision = creatura.get_collision(self, steer_dir, range)
if collision then
local dir2goal = vec_normal(vec_dir(pos, goal))
local dir2col = vec_normal(vec_dir(pos, collision))
local dist2col = vec_dist(pos, collision) - width
local dot_score = vec_dot(dir2col, dir2goal)
local dist_score = (range - dist2col) / range
interest = interest - dot_score
danger = dist_score
end
return interest, danger
end
function creatura.get_context_small(self, goal, steer_dir, interest, danger, range)
local pos = self.object:get_pos()
if not pos then return end
pos = vector.round(pos)
local width = self.width or 0.5
local collision = get_collision_single(vec_add(pos, steer_dir))
if collision then
local dir2goal = vec_normal(vec_dir(pos, goal))
local dir2col = vec_normal(vec_dir(pos, collision))
local dist2col = vec_dist(pos, collision) - width
local dot_score = vec_dot(dir2col, dir2goal)
local dist_score = (range - dist2col) / range
interest = interest - dot_score
danger = dist_score
end
return interest, danger
end
function creatura.get_context_small_aquatic(self, goal, steer_dir, interest, danger, range)
local pos = self.object:get_pos()
if not pos then return end
pos = vector.round(pos)
local width = self.width or 0.5
local pos2 = vec_add(pos, steer_dir)
local collision = minetest.get_item_group(get_node_def(pos2).name, "liquid") < 1 and pos2
if collision then
local dir2goal = vec_normal(vec_dir(pos, goal))
local dir2col = vec_normal(vec_dir(pos, collision))
local dist2col = vec_dist(pos, collision) - width
local dot_score = vec_dot(dir2col, dir2goal)
local dist_score = (range - dist2col) / range
interest = interest - dot_score
danger = dist_score
end
return interest, danger
end
-- Calculate Steering
function creatura.calc_steering(self, goal, get_context, range)
if not goal then return end
get_context = get_context or creatura.get_context_default
local pos, yaw = self.object:get_pos(), self.object:get_yaw()
if not pos or not yaw then return end
range = math.max(range or 2, 2)
local dir2goal = vec_normal(vec_dir(pos, goal))
local output_dir = {x = 0, y = dir2goal.y, z = 0}
-- Cached variables
local dir
for _, _dir in ipairs(steer_directions) do
dir = {x = _dir.x, y = dir2goal.y, z = _dir.z}
local score = vec_dot(dir2goal, dir)
local interest = clamp(score, 0, 1)
local danger = 0
if interest > 0 then -- Direction is within 90 degrees of goal
interest, danger = get_context(self, goal, dir, interest, danger, range)
end
score = interest - danger
output_dir = vector.add(output_dir, vector.multiply(dir, score))
end
return vec_normal(output_dir)
end
-- DEPRECATED
function creatura.get_context_steering(self, goal, range, water)
local context = creatura.get_context_default
local width, height = self.width, self.height
if width > 0.5
or height > 1 then
context = creatura.get_context_large
elseif water then
context = creatura.get_context_small_aquatic
end
return creatura.calc_steering(self, goal, context, range)
end
-------------
-- Actions --
-------------
-- Actions are more specific behaviors used
-- to compose a Utility.
-- Move
function creatura.action_move(self, pos2, timeout, method, speed_factor, anim)
local timer = timeout or 4
local function func(_self)
timer = timer - _self.dtime
self:animate(anim or "walk")
local safe = true
if _self.max_fall
and _self.max_fall > 0 then
local pos = self.object:get_pos()
if not pos then return end
safe = _self:is_pos_safe(pos2)
end
if timer <= 0
or not safe
or _self:move_to(pos2, method or "creatura:obstacle_avoidance", speed_factor or 0.5) then
return true
end
end
self:set_action(func)
end
creatura.action_walk = creatura.action_move -- Support for outdated versions
-- Idle
function creatura.action_idle(self, time, anim)
local timer = time or 1
local function func(_self)
_self:set_gravity(-9.8)
_self:halt()
_self:animate(anim or "stand")
timer = timer - _self.dtime
if timer <= 0 then
return true
end
end
self:set_action(func)
end
-- Rotate on Z axis in random direction until 90 degree angle is reached
function creatura.action_fallover(self)
local zrot = 0
local init = false
local dir = 1
local rot = self.object:get_rotation()
local function func(_self)
if not init then
_self:animate("stand")
if random(2) < 2 then
dir = -1
end
init = true
end
rot = _self.object:get_rotation()
local goal = (pi * 0.5) * dir
local step = _self.dtime
if step > 0.5 then step = 0.5 end
zrot = zrot + (pi * dir) * step
_self.object:set_rotation({x = rot.x, y = rot.y, z = zrot})
if (dir > 0 and zrot >= goal)
or (dir < 0 and zrot <= goal) then return true end
end
self:set_action(func)
end
----------------------
-- Movement Methods --
----------------------
-- Pathfinding
--[[local function trim_path(pos, path)
if #path < 2 then return end
local trim = false
local closest
for i = #path, 1, -1 do
if not path[i] then break end
if (closest
and vec_dist(pos, path[i]) > vec_dist(pos, path[closest]))
or trim then
table.remove(path, i)
trim = true
else
closest = i
end
end
return path
end]]
creatura.register_movement_method("creatura:pathfind_theta", function(self)
local path = {}
local steer_to
local steer_int = 0
local arrival_threshold = clamp(self.width, 0.5, 1)
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < arrival_threshold then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
local path_dir = #path > 0 and vec_dir(pos, path[2] or path[1])
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
steer_to = path_dir or (steer_int <= 0 and creatura.calc_steering(_self, goal)) or steer_to
path = (#path > 0 and path) or (creatura.pathfinder.find_path_theta(_self, pos, goal) or {})
if path_dir
and ((path[2] and vec_dist(pos, path[2]) < arrival_threshold)
or vec_dist(pos, path[1]) < arrival_threshold) then
table.remove(path, 1)
end
-- Apply Movement
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
creatura.register_movement_method("creatura:pathfind", function(self)
local path = {}
local steer_to
local steer_int = 0
local arrival_threshold = clamp(self.width, 0.5, 1)
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < arrival_threshold then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
local path_dir = #path > 0 and vec_dir(pos, path[2] or path[1])
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
steer_to = path_dir or (steer_int <= 0 and creatura.calc_steering(_self, goal)) or steer_to
path = (#path > 0 and path) or (creatura.pathfinder.find_path(_self, pos, goal) or {})
if path_dir
and ((path[2] and vec_dist(pos, path[2]) < arrival_threshold + 0.5)
or vec_dist(pos, path[1]) < arrival_threshold) then
table.remove(path, 1)
end
-- Apply Movement
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
-- Steering
creatura.register_movement_method("creatura:steer_small", function(self)
local steer_to
local steer_int = 0
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
steer_to = (steer_int <= 0 and creatura.calc_steering(_self, goal)) or steer_to
-- Apply Movement
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
creatura.register_movement_method("creatura:steer_large", function(self)
local steer_to
local steer_int = 0
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
steer_to = (steer_int <= 0 and creatura.calc_steering(_self, goal, creatura.get_context_large)) or steer_to
-- Apply Movement
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
creatura.register_movement_method("creatura:walk_simple", function(self)
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
-- Apply Movement
_self:turn_to(dir2yaw(vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
-- Deprecated
creatura.register_movement_method("creatura:context_based_steering", function(self)
local steer_to
local steer_int = 0
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
steer_to = (steer_int <= 0 and creatura.calc_steering(_self, goal, creatura.get_context_large)) or steer_to
-- Apply Movement
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
creatura.register_movement_method("creatura:obstacle_avoidance", function(self)
local box = clamp(self.width, 0.5, 1.5)
local steer_to
local steer_timer = 0.25
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos then return end
self:set_gravity(-9.8)
-- Return true when goal is reached
if vec_dist(pos, goal) < box * 1.33 then
_self:halt()
return true
end
steer_timer = (steer_timer > 0 and steer_timer - _self.dtime) or 0.25
-- Get movement direction
steer_to = (steer_timer > 0 and steer_to) or (steer_timer <= 0 and get_avoidance_dir(_self))
local goal_dir = steer_to or vec_dir(pos, goal)
pos.y = pos.y + goal_dir.y
local yaw = _self.object:get_yaw()
local goal_yaw = dir2yaw(goal_dir)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
local turn_rate = abs(_self.turn_rate or 5)
-- Movement
local yaw_diff = abs(diff(yaw, goal_yaw))
if yaw_diff < pi * 0.25
or steer_to then
_self:set_forward_velocity(speed)
else
_self:set_forward_velocity(speed * 0.33)
end
_self:turn_to(goal_yaw, turn_rate)
end
return func
end)
+1248
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
name = creatura
description = A performant, semi-modular mob API
+805
View File
@@ -0,0 +1,805 @@
-----------------
-- Pathfinding --
-----------------
local a_star_alloted_time = tonumber(minetest.settings:get("creatura_a_star_alloted_time")) or 500
local theta_star_alloted_time = tonumber(minetest.settings:get("creatura_theta_star_alloted_time")) or 700
local floor = math.floor
local abs = math.abs
local vec_dist, vec_round = vector.distance, vector.round
local moveable = creatura.is_pos_moveable
local function get_distance(start_pos, end_pos)
local distX = abs(start_pos.x - end_pos.x)
local distZ = abs(start_pos.z - end_pos.z)
if distX > distZ then
return 14 * distZ + 10 * (distX - distZ)
else
return 14 * distX + 10 * (distZ - distX)
end
end
local function get_distance_to_neighbor(start_pos, end_pos)
local distX = abs(start_pos.x - end_pos.x)
local distY = abs(start_pos.y - end_pos.y)
local distZ = abs(start_pos.z - end_pos.z)
if distX > distZ then
return (14 * distZ + 10 * (distX - distZ)) * (distY + 1)
else
return (14 * distX + 10 * (distZ - distX)) * (distY + 1)
end
end
local function is_on_ground(pos)
local ground = {
x = pos.x,
y = pos.y - 1,
z = pos.z
}
if creatura.get_node_def(ground).walkable then
return true
end
return false
end
local function vec_raise(v, n)
return {x = v.x, y = v.y + n, z = v.z}
end
local function get_line_of_sight(a, b)
local steps = floor(vec_dist(a, b))
local line = {}
for i = 0, steps do
local pos
if steps > 0 then
pos = {
x = a.x + (b.x - a.x) * (i / steps),
y = a.y + (b.y - a.y) * (i / steps),
z = a.z + (b.z - a.z) * (i / steps)
}
else
pos = a
end
table.insert(line, pos)
end
if #line < 1 then
return false
else
for i = 1, #line do
local node = minetest.get_node(line[i])
if creatura.get_node_def(node.name).walkable then
return false
end
end
end
return true
end
-- Find a path from start to goal
--[[local function debugpart(pos, time, tex)
minetest.add_particle({
pos = pos,
texture = tex or "creatura_particle_red.png",
expirationtime = time or 0.1,
glow = 6,
size = 12
})
end]]
local c_air = minetest.get_content_id("air")
local function is_pos_moveable_vm(pos, width, height, area, data)
pos = vector.round(pos)
local pos1 = {
x = pos.x - math.ceil(width),
y = pos.y,
z = pos.z - math.ceil(width)
}
local pos2 = {
x = pos.x + math.ceil(width),
y = pos.y + math.ceil(height),
z = pos.z + math.ceil(width)
}
for z = pos1.z, pos2.z do
for y = pos1.y, pos2.y do
for x = pos1.x, pos2.x do
if not area:contains(x, y, z) then return false end
local vi = area:index(x, y, z)
local c = data[vi]
if c ~= c_air then
local c_name = minetest.get_name_from_content_id(c)
if creatura.get_node_def(c_name).walkable then
return false
end
end
end
end
end
return true
end
local vm_buffer = {}
function creatura.find_lvm_path(self, start, goal, obj_width, obj_height, max_open, climb, fly, swim)
climb = climb or false
fly = fly or false
swim = swim or false
if vec_dist(start, goal) > (self.tracking_range or 128) then return {} end
self._path_data.start = start
local path_neighbors = {
{x = 1, y = 0, z = 0},
{x = 1, y = 0, z = 1},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = -1, y = 0, z = -1},
{x = 0, y = 0, z = -1},
{x = 1, y = 0, z = -1}
}
if climb then
table.insert(path_neighbors, {x = 0, y = 1, z = 0})
end
if fly
or swim then
path_neighbors = {
-- Central
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
-- Directly Up or Down
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
end
local function get_neighbors(pos, width, height, tbl, open, closed, vm_area, vm_data)
local result = {}
for i = 1, #tbl do
local neighbor = vector.add(pos, tbl[i])
if not vm_area or not vm_data or not vm_area:containsp(neighbor) then return end
local can_move = (not swim and get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)) or true
if open[minetest.hash_node_position(neighbor)]
or closed[minetest.hash_node_position(neighbor)] then
can_move = false
end
if can_move then
can_move = is_pos_moveable_vm(neighbor, width, height, vm_area, vm_data)
if not fly and not swim then
if not can_move then -- Step Up
local step = vec_raise(neighbor, 1)
can_move = is_pos_moveable_vm(vec_round(step), width, height, vm_area, vm_data)
neighbor = vec_round(step)
else
local step = creatura.get_ground_level(vector.new(neighbor), 1)
if step.y < neighbor.y
and is_pos_moveable_vm(vec_round(step), width, height, vm_area, vm_data) then
neighbor = step
end
end
end
end
if vector.equals(neighbor, goal) then
can_move = true
end
if can_move
and (not swim
or creatura.get_node_def(neighbor).drawtype == "liquid") then
table.insert(result, neighbor)
end
end
return result
end
local function find_path(_start, _goal)
local us_time = minetest.get_us_time()
_start = {
x = floor(_start.x + 0.5),
y = floor(_start.y + 0.5),
z = floor(_start.z + 0.5)
}
_goal = {
x = floor(_goal.x + 0.5),
y = floor(_goal.y + 0.5),
z = floor(_goal.z + 0.5)
}
if _goal.x == _start.x
and _goal.z == _start.z then -- No path can be found
return nil
end
local vm_area = self._path_data.vm_area
local vm_data = self._path_data.vm_data
if not vm_area
or not vm_data then
local vm_center = vector.add(_start, vector.divide(vector.subtract(_goal, _start), 2))
local vm_size = vec_dist(_goal, _start)
if vm_size < 24 then vm_size = 24 end
local e1 = vector.subtract(vm_center, vm_size)
local e2 = vector.add(vm_center, vm_size)
local vm = minetest.get_voxel_manip(e1, e2)
e1, e2 = vm:read_from_map(e1, e2)
vm_area = VoxelArea:new{MinEdge=e1, MaxEdge=e2}
vm_data = vm:get_data(vm_buffer)
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local start_index = minetest.hash_node_position(_start)
openSet[start_index] = {
pos = _start,
parent = nil,
gScore = 0,
fScore = get_distance(_start, _goal)
}
local count = self._path_data.count or 1
while count > 0 do
-- Initialize ID and data
local current_id, current = next(openSet)
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
self._path_data.open = openSet
self._path_data.closedSet = closedSet
local current_start = vec_round(self._path_data.start)
if closedSet[minetest.hash_node_position(current_start)] then
start_index = minetest.hash_node_position(current_start)
end
-- Reconstruct path if end is reached
if ((is_on_ground(_goal)
or fly)
and current_id == minetest.hash_node_position(_goal))
or (not fly
and not is_on_ground(_goal)
and math.abs(_goal.x - current.pos.x) < 1.1
and math.abs(_goal.z - current.pos.z) < 1.1) then
local path = {}
local fail_safe = 0
for _ in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
count = count - 1
local adjacent = get_neighbors(
current.pos,
obj_width,
obj_height,
path_neighbors,
openSet,
closedSet,
vm_area,
vm_data
)
-- Go through neighboring nodes
if not adjacent or #adjacent < 1 then self._path_data = {} return {} end
for i = 1, #adjacent do
local neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
local temp_gScore = current.gScore + get_distance_to_neighbor(current.pos, neighbor.pos)
local new_gScore = 0
if openSet[minetest.hash_node_position(neighbor.pos)] then
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
end
if (temp_gScore < new_gScore
or not openSet[minetest.hash_node_position(neighbor.pos)])
and not closedSet[minetest.hash_node_position(neighbor.pos)] then
if not openSet[minetest.hash_node_position(neighbor.pos)] then
count = count + 1
end
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
end
end
if minetest.get_us_time() - us_time > a_star_alloted_time then
self._path_data = {
start = _start,
open = openSet,
closed = closedSet,
count = count,
vm_area = vm_area,
vm_data = vm_data
}
return {}
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
self._path_data = {}
return nil
end
return find_path(start, goal)
end
function creatura.find_path(self, start, goal, obj_width, obj_height, max_open, climb, fly, swim)
climb = climb or false
fly = fly or false
swim = swim or false
start = self._path_data.start or start
self._path_data.start = start
local path_neighbors = {
{x = 1, y = 0, z = 0},
{x = 1, y = 0, z = 1},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = -1, y = 0, z = -1},
{x = 0, y = 0, z = -1},
{x = 1, y = 0, z = -1}
}
if climb then
table.insert(path_neighbors, {x = 0, y = 1, z = 0})
end
if fly
or swim then
path_neighbors = {
-- Central
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
-- Directly Up or Down
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
end
local function get_neighbors(pos, width, height, tbl, open, closed)
local result = {}
for i = 1, #tbl do
local neighbor = vector.add(pos, tbl[i])
local can_move = (not swim and get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)) or true
if open[minetest.hash_node_position(neighbor)]
or closed[minetest.hash_node_position(neighbor)] then
can_move = false
end
if can_move then
can_move = moveable(neighbor, width, height)
if not fly and not swim then
if not can_move then -- Step Up
local step = vec_raise(neighbor, 1)
can_move = moveable(vec_round(step), width, height)
neighbor = vec_round(step)
else
local step = creatura.get_ground_level(vector.new(neighbor), 1)
if step.y < neighbor.y
and moveable(vec_round(step), width, height) then
neighbor = step
end
end
end
end
if vector.equals(neighbor, goal) then
can_move = true
end
if can_move
and (not swim
or creatura.get_node_def(neighbor).drawtype == "liquid") then
table.insert(result, neighbor)
end
end
return result
end
local function find_path(_start, _goal)
local us_time = minetest.get_us_time()
_start = {
x = floor(_start.x + 0.5),
y = floor(_start.y + 0.5),
z = floor(_start.z + 0.5)
}
_goal = {
x = floor(_goal.x + 0.5),
y = floor(_goal.y + 0.5),
z = floor(_goal.z + 0.5)
}
if _goal.x == _start.x
and _goal.z == _start.z then -- No path can be found
return nil
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local start_index = minetest.hash_node_position(_start)
openSet[start_index] = {
pos = _start,
parent = nil,
gScore = 0,
fScore = get_distance(_start, _goal)
}
local count = self._path_data.count or 1
while count > 0 do
if minetest.get_us_time() - us_time > a_star_alloted_time then
self._path_data = {
start = _start,
open = openSet,
closed = closedSet,
count = count
}
return
end
-- Initialize ID and data
local current_id, current = next(openSet)
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
self._path_data.open = openSet
self._path_data.closedSet = closedSet
-- Reconstruct path if end is reached
if ((is_on_ground(_goal)
or fly)
and current_id == minetest.hash_node_position(_goal))
or (not fly
and not is_on_ground(_goal)
and _goal.x == current.pos.x
and _goal.z == current.pos.z) then
local path = {}
local fail_safe = 0
for _ in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
count = count - 1
local adjacent = get_neighbors(current.pos, obj_width, obj_height, path_neighbors, openSet, closedSet)
-- Go through neighboring nodes
for i = 1, #adjacent do
local neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
local temp_gScore = current.gScore + get_distance_to_neighbor(current.pos, neighbor.pos)
local new_gScore = 0
if openSet[minetest.hash_node_position(neighbor.pos)] then
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
end
if (temp_gScore < new_gScore
or not openSet[minetest.hash_node_position(neighbor.pos)])
and not closedSet[minetest.hash_node_position(neighbor.pos)] then
if not openSet[minetest.hash_node_position(neighbor.pos)] then
count = count + 1
end
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
end
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
self._path_data = {}
return nil
end
return find_path(start, goal)
end
------------
-- Theta* --
------------
function creatura.find_theta_path(self, start, goal, obj_width, obj_height, max_open, climb, fly, swim)
climb = climb or false
fly = fly or false
swim = swim or false
start = self._path_data.start or start
self._path_data.start = start
local path_neighbors = {
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
}
if climb then
table.insert(path_neighbors, {x = 0, y = 1, z = 0})
end
if fly
or swim then
path_neighbors = {
-- Central
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
-- Directly Up or Down
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
end
local function get_neighbors(pos, width, height, tbl, open, closed)
local result = {}
for i = 1, #tbl do
local neighbor = vector.add(pos, tbl[i])
if neighbor.y == pos.y
and not fly
and not swim then
neighbor = creatura.get_ground_level(neighbor, 1)
end
local can_move = get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)
if swim then
can_move = true
end
if not moveable(vec_raise(neighbor, -0.49), width, height) then
can_move = false
if neighbor.y == pos.y
and moveable(vec_raise(neighbor, 0.51), width, height) then
neighbor = vec_raise(neighbor, 1)
can_move = true
end
end
if vector.equals(neighbor, goal) then
can_move = true
end
if open[minetest.hash_node_position(neighbor)]
or closed[minetest.hash_node_position(neighbor)] then
can_move = false
end
if can_move
and ((is_on_ground(neighbor)
or (fly or swim))
or (neighbor.x == pos.x
and neighbor.z == pos.z
and climb))
and (not swim
or creatura.get_node_def(neighbor).drawtype == "liquid") then
table.insert(result, neighbor)
end
end
return result
end
local function find_path(_start, _goal)
local us_time = minetest.get_us_time()
_start = {
x = floor(_start.x + 0.5),
y = floor(_start.y + 0.5),
z = floor(_start.z + 0.5)
}
_goal = {
x = floor(_goal.x + 0.5),
y = floor(_goal.y + 0.5),
z = floor(_goal.z + 0.5)
}
if _goal.x == _start.x
and _goal.z == _start.z then -- No path can be found
return nil
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local start_index = minetest.hash_node_position(_start)
openSet[start_index] = {
pos = _start,
parent = nil,
gScore = 0,
fScore = get_distance(_start, _goal)
}
local count = self._path_data.count or 1
while count > 0 do
if minetest.get_us_time() - us_time > theta_star_alloted_time then
self._path_data = {
start = _start,
open = openSet,
closed = closedSet,
count = count
}
return
end
-- Initialize ID and data
local current_id, current = next(openSet)
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
-- Reconstruct path if end is reached
if (is_on_ground(_goal)
and current_id == minetest.hash_node_position(_goal))
or (not is_on_ground(_goal)
and _goal.x == current.pos.x
and _goal.z == current.pos.z) then
local path = {}
local fail_safe = 0
for _ in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
count = count - 1
local adjacent = get_neighbors(current.pos, obj_width, obj_height, path_neighbors, openSet, closedSet)
-- Go through neighboring nodes
for i = 1, #adjacent do
local neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
if not openSet[minetest.hash_node_position(neighbor.pos)]
and not closedSet[minetest.hash_node_position(neighbor.pos)] then
local current_parent = closedSet[current.parent] or closedSet[start_index]
if not current_parent then
current_parent = openSet[current.parent] or openSet[start_index]
end
if current_parent
and get_line_of_sight(current_parent.pos, neighbor.pos) then
local temp_gScore = current_parent.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
local new_gScore = 999
if openSet[minetest.hash_node_position(neighbor.pos)] then
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
end
if temp_gScore < new_gScore then
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
neighbor.parent = minetest.hash_node_position(current_parent.pos)
if openSet[minetest.hash_node_position(neighbor.pos)] then
openSet[minetest.hash_node_position(neighbor.pos)] = nil
end
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
count = count + 1
end
else
local temp_gScore = current.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
local new_gScore = 999
if openSet[minetest.hash_node_position(neighbor.pos)] then
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
end
if temp_gScore < new_gScore then
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
if openSet[minetest.hash_node_position(neighbor.pos)] then
openSet[minetest.hash_node_position(neighbor.pos)] = nil
end
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
count = count + 1
end
end
end
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
self._path_data = {}
return nil
end
return find_path(start, goal)
end
+627
View File
@@ -0,0 +1,627 @@
-----------------
-- Pathfinding --
-----------------
local a_star_alloted_time = tonumber(minetest.settings:get("creatura_a_star_alloted_time")) or 500
local theta_star_alloted_time = tonumber(minetest.settings:get("creatura_theta_star_alloted_time")) or 700
creatura.pathfinder = {}
local max_open = 300
-- Math
local floor = math.floor
local abs = math.abs
local vec_add, vec_dist, vec_new, vec_round = vector.add, vector.distance, vector.new, vector.round
local function vec_raise(v, n)
return {x = v.x, y = v.y + n, z = v.z}
end
-- Heuristic
local function get_distance(start_pos, end_pos)
local distX = abs(start_pos.x - end_pos.x)
local distZ = abs(start_pos.z - end_pos.z)
if distX > distZ then
return 14 * distZ + 10 * (distX - distZ)
else
return 14 * distX + 10 * (distZ - distX)
end
end
local function get_distance_to_neighbor(start_pos, end_pos)
local distX = abs(start_pos.x - end_pos.x)
local distY = abs(start_pos.y - end_pos.y)
local distZ = abs(start_pos.z - end_pos.z)
if distX > distZ then
return (14 * distZ + 10 * (distX - distZ)) * (distY + 1)
else
return (14 * distX + 10 * (distZ - distX)) * (distY + 1)
end
end
-- Blocked Movement Checks
local is_blocked = creatura.is_blocked
local function get_line_of_sight(a, b)
local steps = floor(vec_dist(a, b))
local line = {}
for i = 0, steps do
local pos
if steps > 0 then
pos = {
x = a.x + (b.x - a.x) * (i / steps),
y = a.y + (b.y - a.y) * (i / steps),
z = a.z + (b.z - a.z) * (i / steps)
}
else
pos = a
end
table.insert(line, pos)
end
if #line < 1 then
return false
else
for i = 1, #line do
local node = minetest.get_node(line[i])
if creatura.get_node_def(node.name).walkable then
return false
end
end
end
return true
end
local function is_on_ground(pos)
local ground = {
x = pos.x,
y = pos.y - 1,
z = pos.z
}
if creatura.get_node_def(ground).walkable then
return true
end
return false
end
-- Neighbor Check Grids
local neighbor_grid = {
{x = 1, y = 0, z = 0},
{x = 1, y = 0, z = 1},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = -1, y = 0, z = -1},
{x = 0, y = 0, z = -1},
{x = 1, y = 0, z = -1}
}
local neighbor_grid_climb = {
{x = 1, y = 0, z = 0},
{x = 1, y = 0, z = 1},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = -1, y = 0, z = -1},
{x = 0, y = 0, z = -1},
{x = 1, y = 0, z = -1},
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
local neighbor_grid_3d = {
-- Central
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
-- Directly Up or Down
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
-- Get Neighbors
local function get_neighbors(pos, width, height, open, closed, parent, evaluated)
local result = {}
local neighbor
local can_move
local hashed_pos
local step
for i = 1, #neighbor_grid do
neighbor = vec_add(pos, neighbor_grid[i])
can_move = get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)
hashed_pos = minetest.hash_node_position(neighbor)
if parent
and vec_dist(parent, neighbor) < vec_dist(pos, neighbor) then
can_move = false
end
if open[hashed_pos]
or closed[hashed_pos]
or evaluated[hashed_pos] then
can_move = false
elseif can_move then
can_move = not is_blocked(neighbor, width, height)
if not can_move then -- Step Up
step = vec_raise(neighbor, 1)
can_move = not is_blocked(vec_round(step), width, height)
neighbor = vec_round(step)
else
step = creatura.get_ground_level(vec_new(neighbor), 1)
if step.y < neighbor.y
and not is_blocked(vec_round(step), width, height) then
neighbor = step
end
end
end
if can_move then
table.insert(result, neighbor)
end
evaluated[hashed_pos] = true
end
return result
end
function creatura.pathfinder.get_neighbors_climb(pos, width, height, open, closed)
local result = {}
local neighbor
local can_move
local hashed_pos
local step
for i = 1, #neighbor_grid_climb do
neighbor = vec_add(pos, neighbor_grid_climb[i])
can_move = get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)
hashed_pos = minetest.hash_node_position(neighbor)
if open[hashed_pos]
or closed[hashed_pos] then
can_move = false
elseif can_move then
can_move = not is_blocked(neighbor, width, height)
if not can_move then -- Step Up
step = vec_raise(neighbor, 1)
can_move = not is_blocked(vec_round(step), width, height)
neighbor = vec_round(step)
elseif i < 9 then
step = creatura.get_ground_level(vec_new(neighbor), 1)
if step.y < neighbor.y
and not is_blocked(vec_round(step), width, height) then
neighbor = step
end
end
end
if can_move then
table.insert(result, neighbor)
end
end
return result
end
function creatura.pathfinder.get_neighbors_fly(pos, width, height, open, closed, parent)
local result = {}
local neighbor
local can_move
local hashed_pos
for i = 1, #neighbor_grid_3d do
neighbor = vec_add(pos, neighbor_grid_3d[i])
can_move = get_line_of_sight({x = pos.x, y = pos.y, z = pos.z}, neighbor)
hashed_pos = minetest.hash_node_position(neighbor)
if parent
and vec_dist(parent, neighbor) < vec_dist(pos, neighbor) then
can_move = false
end
if open[hashed_pos]
or closed[hashed_pos] then
can_move = false
elseif can_move then
can_move = not is_blocked(neighbor, width, height)
end
if can_move then
table.insert(result, neighbor)
end
end
return result, true
end
function creatura.pathfinder.get_neighbors_swim(pos, width, height, open, closed, parent)
local result = {}
local neighbor
local can_move
local hashed_pos
for i = 1, #neighbor_grid_3d do
neighbor = vec_add(pos, neighbor_grid_3d[i])
can_move = get_line_of_sight({x = pos.x, y = pos.y, z = pos.z}, neighbor)
hashed_pos = minetest.hash_node_position(neighbor)
if (parent
and vec_dist(parent, neighbor) < vec_dist(pos, neighbor))
or creatura.get_node_def(neighbor).drawtype ~= "liquid" then
can_move = false
end
if open[hashed_pos]
or closed[hashed_pos] then
can_move = false
elseif can_move then
can_move = not is_blocked(neighbor, width, height)
end
if can_move then
table.insert(result, neighbor)
end
end
return result, true
end
-- A*
function creatura.pathfinder.find_path(self, pos1, pos2, neighbor_func)
local us_time = minetest.get_us_time()
local check_vertical = false
neighbor_func = neighbor_func or get_neighbors
local start = self._path_data.start or {
x = floor(pos1.x + 0.5),
y = floor(pos1.y + 0.5),
z = floor(pos1.z + 0.5)
}
local goal = {
x = floor(pos2.x + 0.5),
y = floor(pos2.y + 0.5),
z = floor(pos2.z + 0.5)
}
self._path_data.start = start
if goal.x == start.x
and goal.z == start.z then -- No path can be found
self._path_data = {}
return
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local evaluated = {}
local start_index = minetest.hash_node_position(start)
openSet[start_index] = {
pos = start,
parent = nil,
gScore = 0,
fScore = get_distance(start, goal)
}
local count = self._path_data.count or 1
local current_id, current
local adjacent
local neighbor
local temp_gScore
local new_gScore
local hCost
local hashed_pos
local parent_open
local parent_closed
while count > 0 do
-- Initialize ID and data
current_id, current = next(openSet)
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
if not current_id then self._path_data = {} return end -- failsafe
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
if ((check_vertical or is_on_ground(goal))
and current_id == minetest.hash_node_position(goal))
or ((not check_vertical and not is_on_ground(goal))
and goal.x == current.pos.x
and goal.z == current.pos.z) then
local path = {}
local fail_safe = 0
for _ in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then self._path_data = {} return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
parent_open = openSet[current.parent]
parent_closed = closedSet[current.parent]
adjacent, check_vertical = neighbor_func(
current.pos,
self.width,
self.height,
openSet,
closedSet,
(parent_closed and parent_closed.pos) or (parent_open and parent_open.pos),
evaluated
)
-- Fly, Swim, and Climb all return true for check_vertical to properly check if goal has been reached
-- Go through neighboring nodes
for i = 1, #adjacent do
neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
temp_gScore = current.gScore + get_distance_to_neighbor(current.pos, neighbor.pos)
new_gScore = 0
hashed_pos = minetest.hash_node_position(neighbor.pos)
if openSet[hashed_pos] then
new_gScore = openSet[hashed_pos].gScore
end
if (temp_gScore < new_gScore
or not openSet[hashed_pos])
and not closedSet[hashed_pos] then
if not openSet[hashed_pos] then
count = count + 1
end
hCost = get_distance_to_neighbor(neighbor.pos, goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
openSet[hashed_pos] = neighbor
end
end
if minetest.get_us_time() - us_time > a_star_alloted_time then
self._path_data = {
start = start,
open = openSet,
closed = closedSet,
count = count
}
return
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
end
-- Theta*
function creatura.pathfinder.find_path_theta(self, pos1, pos2, neighbor_func)
local us_time = minetest.get_us_time()
local check_vertical = false
neighbor_func = neighbor_func or get_neighbors
local start = self._path_data.start or {
x = floor(pos1.x + 0.5),
y = floor(pos1.y + 0.5),
z = floor(pos1.z + 0.5)
}
local goal = {
x = floor(pos2.x + 0.5),
y = floor(pos2.y + 0.5),
z = floor(pos2.z + 0.5)
}
self._path_data.start = start
if goal.x == start.x
and goal.z == start.z then -- No path can be found
return
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local evaluated = {}
local start_index = minetest.hash_node_position(start)
openSet[start_index] = {
pos = start,
parent = nil,
gScore = 0,
fScore = get_distance(start, goal)
}
local count = self._path_data.count or 1
local current_id, current
local current_parent
local adjacent
local neighbor
local temp_gScore
local new_gScore
local hCost
local hashed_pos
local parent_open
local parent_closed
while count > 0 do
-- Initialize ID and data
current_id, current = next(openSet)
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
if not current_id then return end -- failsafe
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
if ((check_vertical or is_on_ground(goal))
and current_id == minetest.hash_node_position(goal))
or ((not check_vertical and not is_on_ground(goal))
and goal.x == current.pos.x
and goal.z == current.pos.z) then
local path = {}
local fail_safe = 0
for _ in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
parent_open = openSet[current.parent]
parent_closed = closedSet[current.parent]
adjacent, check_vertical = neighbor_func(
current.pos,
self.width,
self.height,
openSet,
closedSet,
(parent_closed and parent_closed.pos) or (parent_open and parent_open.pos),
evaluated
)
-- Fly, Swim, and Climb all return true for check_vertical to properly check if goal has been reached
-- Go through neighboring nodes
for i = 1, #adjacent do
neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
hashed_pos = minetest.hash_node_position(neighbor.pos)
if not openSet[hashed_pos]
and not closedSet[hashed_pos] then
current_parent = closedSet[current.parent] or closedSet[start_index]
if not current_parent then
current_parent = openSet[current.parent] or openSet[start_index]
end
if current_parent
and get_line_of_sight(current_parent.pos, neighbor.pos) then
temp_gScore = current_parent.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
new_gScore = 999
if openSet[hashed_pos] then
new_gScore = openSet[hashed_pos].gScore
end
if temp_gScore < new_gScore then
hCost = get_distance_to_neighbor(neighbor.pos, goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
neighbor.parent = minetest.hash_node_position(current_parent.pos)
openSet[hashed_pos] = neighbor
count = count + 1
end
else
temp_gScore = current.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
new_gScore = 999
if openSet[hashed_pos] then
new_gScore = openSet[hashed_pos].gScore
end
if temp_gScore < new_gScore then
hCost = get_distance_to_neighbor(neighbor.pos, goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
openSet[hashed_pos] = neighbor
count = count + 1
end
end
end
end
if minetest.get_us_time() - us_time > theta_star_alloted_time then
self._path_data = {
start = start,
open = openSet,
closed = closedSet,
count = count
}
return
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
end
+32
View File
@@ -0,0 +1,32 @@
# How mobs step up nodes.
#
# - Simple means mobs use Minetests builtin stepping.
# - Fancy means mobs will step up nodes with a quick hop but can cause lag.
creatura_step_type (Step Type) enum simple simple,fancy
# How often (in seconds) the spawn ABM is called
creatura_spawn_interval (Spawn ABM Interval) int 10
# Allows Mobs to spawn during chunk generation (If dependent mods use spawn_on_gen)
creatura_mapgen_spawning (Mapgen Spawning) bool true
# How many chunks are generated before a Mob can spawn
creatura_mapgen_spawn_interval (Mapgen Spawning Interval) int 64
# How many Mobs can be a in a Mapblock before ABM spawning is blocked
creatura_mapblock_limit (Max Mobs per Mapblock) int 12
# How many Mobs can be within Active Block Send Range of an attempted spawn before stopping attempt
creatura_abr_limit (Max Mobs within ABR) int 24
# Minimum distance to a player for ABM Spawning
creatura_min_abm_dist (Minimum ABM Spawning Distance) int 32
# Allows Mobs to spawn in protected areas
creatura_protected_spawn (Protected Area Spawning) bool true
# Allotted time (in μs) per step for A* pathfinding (lower means less lag but slower pathfinding)
creatura_a_star_alloted_time (A* Pathfinding Alloted time per step) int 500
# Allotted time (in μs) per step for Theta* pathfinding (lower means less lag but slower pathfinding)
creatura_theta_star_alloted_time (Theta* Pathfinding Alloted time per step) int 700
Binary file not shown.
Binary file not shown.
Binary file not shown.
+573
View File
@@ -0,0 +1,573 @@
--------------
-- Spawning --
--------------
creatura.registered_mob_spawns = {}
creatura.registered_on_spawns = {}
-- Math --
local abs = math.abs
local ceil = math.ceil
local pi = math.pi
local random = math.random
local min = math.min
local vec_add, vec_dist, vec_sub = vector.add, vector.distance, vector.subtract
-- Utility Functions --
local function format_name(str)
if str then
if str:match(":") then str = str:split(":")[2] end
return (string.gsub(" " .. str, "%W%l", string.upper):sub(2):gsub("_", " "))
end
end
local function table_contains(tbl, val)
for _, v in pairs(tbl) do
if v == val then
return true
end
end
return false
end
local function pos_meets_params(pos, def)
if not minetest.find_nodes_in_area(pos, pos, def.nodes) then return false end
if not minetest.find_node_near(pos, 1, def.neighbors) then return false end
return true
end
local function can_spawn(pos, width, height)
local pos2
local w_iter = width / ceil(width)
for y = 0, height, height / ceil(height) do
for z = -width, width, w_iter do
for x = -width, width, w_iter do
pos2 = {x = pos.x + x, y = pos.y + y, z = pos.z + z}
local def = creatura.get_node_def(pos2)
if def.walkable then return false end
end
end
end
return true
end
local function do_on_spawn(pos, obj)
local name = obj and obj:get_luaentity().name
if not name then return end
local spawn_functions = creatura.registered_on_spawns[name] or {}
if #spawn_functions > 0 then
for _, func in ipairs(spawn_functions) do
func(obj:get_luaentity(), pos)
if not obj:get_yaw() then break end
end
end
end
----------------
-- Spawn Item --
----------------
local creative = minetest.settings:get_bool("creative_mode")
function creatura.register_spawn_item(name, def)
local inventory_image
if not def.inventory_image
and ((def.col1 and def.col2)
or (def.hex_primary and def.hex_secondary)) then
local primary = def.col1 or def.hex_primary
local secondary = def.col2 or def.hex_secondary
local base = "(creatura_spawning_crystal_primary.png^[multiply:#" .. primary .. ")"
local spots = "(creatura_spawning_crystal_secondary.png^[multiply:#" .. secondary .. ")"
inventory_image = base .. "^" .. spots
end
local mod_name = name:split(":")[1]
local mob_name = name:split(":")[2]
def.description = def.description or "Spawn " .. format_name(name)
def.inventory_image = def.inventory_image or inventory_image
def.on_place = function(itemstack, player, pointed_thing)
-- If the player right-clicks something like a chest or item frame then
-- run the node's on_rightclick callback
local under = pointed_thing.under
local node = minetest.get_node(under)
local node_def = minetest.registered_nodes[node.name]
if node_def and node_def.on_rightclick and
not (player and player:is_player() and
player:get_player_control().sneak) then
return node_def.on_rightclick(under, node, player, itemstack,
pointed_thing) or itemstack
end
-- Otherwise spawn the mob
local pos = minetest.get_pointed_thing_position(pointed_thing, true)
if minetest.is_protected(pos, player and player:get_player_name() or "") then return end
local mobdef = minetest.registered_entities[name]
local spawn_offset = abs(mobdef.collisionbox[2])
pos.y = (pos.y - 0.49) + spawn_offset
if def.antispam then
local objs = minetest.get_objects_in_area(vec_sub(pos, 0.51), vec_add(pos, 0.51))
for _, obj in ipairs(objs) do
if obj
and obj:get_luaentity()
and obj:get_luaentity().name == name then
return
end
end
end
local object = minetest.add_entity(pos, name)
if object then
object:set_yaw(random(0, pi * 2))
object:get_luaentity().last_yaw = object:get_yaw()
if def.on_spawn then
def.on_spawn(object:get_luaentity(), player)
end
end
if not minetest.is_creative_enabled(player:get_player_name())
or def.consume_in_creative then
itemstack:take_item()
return itemstack
end
end
minetest.register_craftitem(def.itemstring or (mod_name .. ":spawn_" .. mob_name), def)
end
function creatura.register_on_spawn(name, func)
if not creatura.registered_on_spawns[name] then
creatura.registered_on_spawns[name] = {}
end
table.insert(creatura.registered_on_spawns[name], func)
end
--------------
-- Spawning --
--------------
--[[creatura.register_abm_spawn("mymod:mymob", {
chance = 3000,
interval = 30,
min_height = 0,
max_height = 128,
min_light = 1,
max_light = 15,
min_group = 1,
max_group = 4,
nodes = {"group:soil", "group:stone"},
neighbors = {"air"},
spawn_on_load = false,
spawn_in_nodes = false,
spawn_cap = 5
})]]
local protected_spawn = minetest.settings:get_bool("creatura_protected_spawn", true)
local abr = (tonumber(minetest.get_mapgen_setting("active_block_range")) or 4) * 16
local max_per_block = tonumber(minetest.settings:get("creatura_mapblock_limit")) or 12
local max_in_abr = tonumber(minetest.settings:get("creatura_abr_limit")) or 24
local min_abm_dist = min(abr / 2, tonumber(minetest.settings:get("creatura_min_abm_dist")) or 32)
local mobs_spawn = minetest.settings:get_bool("mobs_spawn") ~= false
local mapgen_mobs = {}
function creatura.register_abm_spawn(mob, def)
local chance = def.chance or 3000
local interval = def.interval or 30
local min_height = def.min_height or 0
local max_height = def.max_height or 128
local min_time = def.min_time or 0
local max_time = def.max_time or 24000
local min_light = def.min_light or 1
local max_light = def.max_light or 15
local min_group = def.min_group or 1
local max_group = def.max_group or 4
local block_protected = def.block_protected_spawn or false
local biomes = def.biomes or {}
local nodes = def.nodes or {"group:soil", "group:stone"}
local neighbors = def.neighbors or {"air"}
local spawn_on_load = def.spawn_on_load or false
local spawn_in_nodes = def.spawn_in_nodes or false
local spawn_cap = def.spawn_cap or 5
local function spawn_func(pos, aocw)
if not mobs_spawn then
return
end
if not spawn_in_nodes then
pos.y = pos.y + 1
end
if (not protected_spawn
or block_protected)
and minetest.is_protected(pos, "") then
return
end
local tod = (minetest.get_timeofday() or 0) * 24000
local bounds_in = tod >= min_time and tod <= max_time
local bounds_ex = tod >= max_time or tod <= min_time
if (max_time > min_time and not bounds_in)
or (min_time > max_time and not bounds_ex) then
return
end
local light = minetest.get_node_light(pos) or 7
if light > max_light
or light < min_light then
return
end
if aocw
and aocw >= max_per_block then
return
end
if biomes
and #biomes > 0 then
local biome_id = minetest.get_biome_data(pos).biome
local biome_name = minetest.get_biome_name(biome_id)
local is_spawn_biome = false
for _, biome in ipairs(biomes) do
if biome:match("^" .. biome_name) then
is_spawn_biome = true
break
end
end
if not is_spawn_biome then return end
end
local mob_count = 0
local plyr_found = false
local objects = minetest.get_objects_inside_radius(pos, abr)
for _, object in ipairs(objects) do
local ent = object:get_luaentity()
if ent
and ent.name == mob then
mob_count = mob_count + 1
if mob_count > spawn_cap
or mob_count > max_in_abr then
return
end
end
if object:is_player() then
plyr_found = true
if vec_dist(pos, object:get_pos()) < min_abm_dist then
return
end
end
end
if not plyr_found then
return
end
local mob_def = minetest.registered_entities[mob]
local mob_width = mob_def.collisionbox[4]
local mob_height = mob_def.collisionbox[5]
if not can_spawn(pos, mob_width, mob_height) then
return
end
local group_size = random(min_group or 1, max_group or 1)
local obj
if group_size > 1 then
local offset
local spawn_pos
for _ = 1, group_size do
offset = ceil(mob_width)
spawn_pos = creatura.get_ground_level({
x = pos.x + random(-offset, offset),
y = pos.y,
z = pos.z + random(-offset, offset)
}, 3)
if not can_spawn(spawn_pos, mob_width, mob_height) then
spawn_pos = pos
end
obj = minetest.add_entity(spawn_pos, mob)
do_on_spawn(spawn_pos, obj)
end
else
obj = minetest.add_entity(pos, mob)
do_on_spawn(pos, obj)
end
minetest.log("action",
"[Creatura] [ABM Spawning] Spawned " .. group_size .. " " .. mob .. " at " .. minetest.pos_to_string(pos))
end
minetest.register_abm({
label = mob .. " spawning",
nodenames = nodes,
neighbors = neighbors,
interval = interval,
chance = chance,
min_y = min_height,
max_y = max_height,
catch_up = false,
action = function(pos, _, _, aocw)
spawn_func(pos, aocw)
end
})
if spawn_on_load then
table.insert(mapgen_mobs, mob)
end
creatura.registered_mob_spawns[mob] = {
chance = def.chance or 3000,
interval = def.interval or 30,
min_height = def.min_height or 0,
max_height = def.max_height or 128,
min_time = def.min_time or 0,
max_time = def.max_time or 24000,
min_light = def.min_light or 1,
max_light = def.max_light or 15,
min_group = def.min_group or 1,
max_group = def.max_group or 4,
block_protected = def.block_protected_spawn or false,
biomes = def.biomes or {},
nodes = def.nodes or {"group:soil", "group:stone"},
neighbors = def.neighbors or {"air"},
spawn_on_load = def.spawn_on_load or false,
spawn_in_nodes = def.spawn_in_nodes or false,
spawn_cap = def.spawn_cap or 5
}
end
----------------
-- DEPRECATED --
----------------
-- Mapgen --
minetest.register_node("creatura:spawn_node", {
drawtype = "airlike",
groups = {not_in_creative_inventory = 1},
walkable = false
})
local mapgen_spawning = false
local mapgen_spawning_int = tonumber(minetest.settings:get("creatura_mapgen_spawn_interval")) or 64
if mapgen_spawning then
local chunk_delay = 0
local c_air = minetest.get_content_id("air")
local c_spawn = minetest.get_content_id("creatura:spawn_node")
minetest.register_on_generated(function(minp, maxp)
if chunk_delay > 0 then chunk_delay = chunk_delay - 1 end
local meta_queue = {}
local vm, emin, emax = minetest.get_mapgen_object("voxelmanip")
local area = VoxelArea:new{MinEdge = emin, MaxEdge = emax}
local data = vm:get_data()
local min_x, max_x = minp.x, maxp.x
local min_y, max_y = minp.y, maxp.y
local min_z, max_z = minp.z, maxp.z
local def
local center
local current_biome
local spawn_biomes
local current_pos
for _, mob_name in ipairs(mapgen_mobs) do
local mob_spawned = false
def = creatura.registered_mob_spawns[mob_name]
center = {
x = min_x + (max_x - min_x) * 0.5,
y = min_y + (max_y - min_y) * 0.5,
z = min_z + (max_z - min_z) * 0.5
}
current_biome = minetest.get_biome_name(minetest.get_biome_data(center).biome)
spawn_biomes = def.biomes
if not mob_spawned
and (not spawn_biomes
or table_contains(spawn_biomes, current_biome)) then
for z = min_z + 8, max_z - 7, 8 do
if mob_spawned then break end
for x = min_x + 8, max_x - 7, 8 do
if mob_spawned then break end
for y = min_y, max_y do
local vi = area:index(x, y, z)
if data[vi] == c_air
or data[vi] == c_spawn then
break
end
-- Check if position is outside of vertical bounds
if y > def.max_height
or y < def.min_height then
break
end
current_pos = vector.new(x, y, z)
-- Check if position has required nodes
if not pos_meets_params(current_pos, def) then
break
end
if def.spawn_in_nodes then
-- Add Spawn Node to Map
data[vi] = c_spawn
local group_size = random(def.min_group or 1, def.max_group or 1)
table.insert(meta_queue, {pos = current_pos, mob = mob_name, cluster = group_size})
mob_spawned = true
break
elseif data[area:index(x, y + 1, z)] == c_air then
vi = area:index(x, y + 1, z)
current_pos = vector.new(x, y + 1, z)
-- Add Spawn Node to Map
data[vi] = c_spawn
local group_size = random(def.min_group or 1, def.max_group or 1)
table.insert(meta_queue, {pos = current_pos, mob = mob_name, cluster = group_size})
mob_spawned = true
break
end
end
end
end
end
end
if #meta_queue > 0 then
vm:set_data(data)
vm:write_to_map()
for _, unset_meta in ipairs(meta_queue) do
local pos = unset_meta.pos
local mob = unset_meta.mob
local cluster = unset_meta.cluster
local meta = minetest.get_meta(pos)
meta:set_string("mob", mob)
meta:set_int("cluster", cluster)
end
chunk_delay = mapgen_spawning_int
end
end)
local spawn_interval = tonumber(minetest.settings:get("creatura_spawn_interval")) or 10
minetest.register_abm({
label = "Creatura Spawning",
nodenames = {"creatura:spawn_node"},
interval = spawn_interval,
chance = 1,
action = function(pos)
local plyr_found = false
local objects = minetest.get_objects_inside_radius(pos, abr)
for _, object in ipairs(objects) do
if object:is_player() then
plyr_found = true
break
end
end
if not plyr_found then return end
local meta = minetest.get_meta(pos)
local name = meta:get_string("mob") or ""
if name == "" then minetest.remove_node(pos) return end
local amount = meta:get_int("cluster")
local obj
if amount > 0 then
for _ = 1, amount do
obj = minetest.add_entity(pos, name)
do_on_spawn(pos, obj)
end
minetest.log("action",
"[Creatura] Spawned " .. amount .. " " .. name .. " at " .. minetest.pos_to_string(pos))
else
obj = minetest.add_entity(pos, name)
do_on_spawn(pos, obj)
minetest.log("action",
"[Creatura] Spawned a " .. name .. " at " .. minetest.pos_to_string(pos))
end
minetest.remove_node(pos)
end,
})
end
function creatura.register_mob_spawn(name, def)
local spawn_def = {
chance = def.chance or 5,
min_height = def.min_height or 0,
max_height = def.max_height or 128,
min_time = def.min_time or 0,
max_time = def.max_time or 24000,
min_light = def.min_light or 6,
max_light = def.max_light or 15,
min_group = def.min_group or 1,
max_group = def.max_group or 4,
nodes = def.nodes or nil,
biomes = def.biomes or nil,
--spawn_cluster = def.spawn_cluster or false,
spawn_on_load = def.spawn_on_gen or false,
spawn_in_nodes = def.spawn_in_nodes or false,
spawn_cap = def.spawn_cap or 5,
--send_debug = def.send_debug or false
}
--creatura.registered_mob_spawns[name] = spawn_def
creatura.register_abm_spawn(name, spawn_def)
end
function creatura.register_spawn_egg(name, col1, col2, inventory_image)
if col1 and col2 then
local base = "(creatura_spawning_crystal_primary.png^[multiply:#" .. col1 .. ")"
local spots = "(creatura_spawning_crystal_secondary.png^[multiply:#" .. col2 .. ")"
inventory_image = base .. "^" .. spots
end
local mod_name = name:split(":")[1]
local mob_name = name:split(":")[2]
minetest.register_craftitem(mod_name .. ":spawn_" .. mob_name, {
description = "Spawn " .. format_name(name),
inventory_image = inventory_image,
stack_max = 99,
on_place = function(itemstack, _, pointed_thing)
local mobdef = minetest.registered_entities[name]
local spawn_offset = abs(mobdef.collisionbox[2])
local pos = minetest.get_pointed_thing_position(pointed_thing, true)
pos.y = (pos.y - 0.4) + spawn_offset
local object = minetest.add_entity(pos, name)
if object then
object:set_yaw(random(1, 6))
object:get_luaentity().last_yaw = object:get_yaw()
end
if not creative then
itemstack:take_item()
return itemstack
end
end
})
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

+20 -1
View File
@@ -11,7 +11,7 @@ return function(hearth, api, register_node, construction)
for i, name in ipairs(list) do out[i] = node(name) end
return out
end
M.species_order = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"}
M.species_order = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce", "crab_apple", "wild_cherry", "field_maple"}
M.rocks = {"granite", "basalt", "limestone", "slate", "sandstone"}
-- Moisture/fertility/drainage are normalized habitat preferences, NOT player
-- climate values. The succession owner decides how to consume these hints.
@@ -69,6 +69,15 @@ return function(hearth, api, register_node, construction)
spruce = {height=10, width=5, crown="spire", heat={0,40}, humidity={40,85},
y_min=8, y_max=185, soils={"podzol", "forest_floor", "alpine_turf"},
biomes={"hearth_pine_taiga", "hearth_birch_forest"}, shade=0.85, litter=0.5},
crab_apple = {height=6,width=7,crown="round",heat={35,75},humidity={30,75},
y_min=3,y_max=100,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.65,litter=0.7},
wild_cherry = {height=9,width=7,crown="horizontal",heat={35,75},humidity={30,70},
y_min=4,y_max=120,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.6,litter=0.8},
field_maple = {height=7,width=9,crown="lobed_broad",heat={35,80},humidity={25,75},
y_min=3,y_max=110,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.8,litter=0.8},
}
function M.supports_species(species, surface)
local p = M.profiles[species]
@@ -122,6 +131,16 @@ return function(hearth, api, register_node, construction)
elseif species == "spruce" then
local radius = y >= 1 and math.min(2,math.floor((h-1-y)/3)) or -1
leaf = r <= radius and dx+dz <= radius+1
elseif species == "crab_apple" then
leaf = y>=2 and dx*dx+dz*dz <= (y==h-1 and 2 or (y==2 and 5 or 10))
trunk = trunk or (y==2 and dx+dz<=2 and (dx==0 or dz==0))
elseif species == "wild_cherry" then
leaf = y>=4 and r<=(y==h-1 and 1 or 3) and dx+dz<=4 and (y%2==0 or r<=1)
trunk = trunk or ((y==4 or y==6) and dz==0 and dx<=2)
elseif species == "field_maple" then
leaf = y>=2 and r<=(y==h-1 and 2 or 4) and dx+dz<=(y==h-1 and 3 or 5)
and not (dx==dz and r>=2)
trunk = trunk or (y==3 and dx+dz<=3 and (dx==0 or dz==0))
end
local stem = canonical and "log" or species .. "_log"
local leaves = canonical and "leaves" or species .. "_leaves"
+72 -12
View File
@@ -54,6 +54,9 @@ return function(hearth, api)
add("rowan","Rowan","Sorbus aucuparia","acid",grow,"pollinator,browse,nursery")
add("hazel","Common hazel","Corylus avellana","wood",grow,"browse,ectomycorrhiza,nursery")
add("spruce","Norway spruce","Picea abies","acid",year,"ectomycorrhiza,nursery,resin")
add("crab_apple","Crab apple","Malus sylvestris","wood",grow,"pollinator,browse,nursery")
add("wild_cherry","Wild cherry","Prunus avium","wood",grow,"pollinator,browse,nursery")
add("field_maple","Field maple","Acer campestre","wood",grow,"pollinator,browse,nursery")
group="shrubs"
add("bilberry","Bilberry","Vaccinium myrtillus","acid",grow,"pollinator,browse,nursery",{food=true})
add("blackberry","Pleated blackberry","Rubus plicatus","shrub",grow,"pollinator,browse,nursery",{food=true})
@@ -65,6 +68,10 @@ return function(hearth, api)
add("holly","European holly","Ilex aquifolium","wood",year,"pollinator,browse,nursery",{toxic=true})
add("juniper","Common juniper","Juniperus communis","acid",year,"browse,nursery,resin")
add("guelder_rose","Guelder rose","Viburnum opulus","wetwood",grow,"pollinator,browse,nursery",{toxic=true})
add("honeysuckle","Honeysuckle","Lonicera periclymenum","shrub",grow,"pollinator,nursery",{toxic=true})
add("ivy","Ivy","Hedera helix","shade",year,"pollinator,nursery",{toxic=true})
add("gorse","Gorse","Ulex europaeus","acid",year,"nitrogenfixer,pollinator,nursery",{toxic=true})
add("broom","Broom","Cytisus scoparius","acid",grow,"nitrogenfixer,pollinator,nursery",{toxic=true})
group="grasses"
add("sedge","Greater tussock-sedge","Carex paniculata","bank",grow,"browse,engineer,nursery",{existing=true})
add("bulrush","Bulrush (great reedmace)","Typha latifolia","bank",grow,"engineer,nursery",{existing=true})
@@ -87,6 +94,18 @@ return function(hearth, api)
add("wild_garlic","Wild garlic","Allium ursinum","shade","Spring","pollinator")
add("cowslip","Cowslip","Primula veris","meadow","Spring","pollinator")
add("wood_anemone","Wood anemone","Anemone nemorosa","shade","Spring","pollinator",{toxic=true})
add("dandelion","Dandelion","Taraxacum officinale","meadow",grow,"pollinator,browse")
add("daisy","Daisy","Bellis perennis","meadow",grow,"pollinator")
add("red_clover","Red clover","Trifolium pratense","meadow",grow,"nitrogenfixer,pollinator,browse")
add("birdsfoot_trefoil","Bird's-foot trefoil","Lotus corniculatus","meadow",grow,"nitrogenfixer,pollinator,browse",{toxic=true})
add("ribwort_plantain","Ribwort plantain","Plantago lanceolata","meadow",grow,"browse")
add("selfheal","Selfheal","Prunella vulgaris","meadow",grow,"pollinator")
add("knapweed","Common knapweed","Centaurea nigra","meadow",grow,"pollinator,nursery")
add("oxeye_daisy","Oxeye daisy","Leucanthemum vulgare","meadow",grow,"pollinator")
add("field_scabious","Field scabious","Knautia arvensis","meadow",grow,"pollinator")
add("teasel","Wild teasel","Dipsacus fullonum","meadow",grow,"pollinator,nursery")
add("cow_parsley","Cow parsley","Anthriscus sylvestris","shrub",grow,"pollinator,nursery")
add("garlic_mustard","Garlic mustard","Alliaria petiolata","shade",grow,"pollinator,nursery")
group="ferns_mosses"
add("bracken","Bracken","Pteridium aquilinum","acid",grow,"nursery,allelopathy",{toxic=true})
add("lady_fern","Lady-fern","Athyrium filix-femina","shade",grow,"nursery")
@@ -98,6 +117,7 @@ return function(hearth, api)
add("moss","Cypress-leaved plait-moss","Hypnum cupressiforme","moss",year,"engineer,nursery",{existing=true})
add("fork_moss","Broom fork-moss","Dicranum scoparium","moss",year,"nursery")
add("star_moss","Swan's-neck thyme-moss","Mnium hornum","moss",year,"nursery")
add("holly_fern","Soft shield-fern (contract ID holly fern)","Polystichum setiferum","shade",year,"nursery")
group="fungi"
add("fly_agaric","Fly agaric","Amanita muscaria","litter","Summer,Autumn","ectomycorrhiza",{toxic=true})
add("birch_bolete","Brown birch bolete","Leccinum scabrum","litter","Summer,Autumn","ectomycorrhiza")
@@ -137,6 +157,16 @@ return function(hearth, api)
niche("rowan",{light={5,15},moisture={.3,.85}})
niche("spruce",{moisture={.4,.85},light={4,15}})
niche("hazel",{ph={5,8}})
niche("crab_apple",{light={7,15},moisture={.3,.75},ph={5,8}})
niche("wild_cherry",{light={7,15},moisture={.3,.7},ph={5.5,8}})
niche("field_maple",{light={5,15},moisture={.25,.75},ph={5.5,8}})
niche("gorse",{light={10,15},moisture={.2,.65},ph={3.5,6.5}})
niche("broom",{light={9,15},moisture={.2,.65},ph={4,7}})
niche("ivy",{light={3,15},moisture={.3,.85},ph={5,8}})
niche("birdsfoot_trefoil",{moisture={.2,.7},ph={5,8}})
niche("ribwort_plantain",{moisture={.2,.8}})
niche("field_scabious",{moisture={.2,.65},ph={6,8}})
niche("holly_fern",{light={3,10},moisture={.5,.9},ph={5,8}})
niche("juniper",{ph={4,8},moisture={.15,.6}})
niche("nettle",{light={5,15},nutrients=1})
niche("cowslip",{ph={6,8},moisture={.2,.65}})
@@ -171,8 +201,21 @@ return function(hearth, api)
bluebell="Spring",yarrow="Summer,Autumn",foxglove="Summer",meadowsweet="Summer",
wild_garlic="Spring",cowslip="Spring",wood_anemone="Spring",yellow_waterlily="Summer",
white_waterlily="Summer",water_crowfoot="Spring,Summer",water_mint="Summer,Autumn",
water_forgetmenot="Spring,Summer",brooklime="Spring,Summer"}
water_forgetmenot="Spring,Summer",brooklime="Spring,Summer",
crab_apple="Spring",wild_cherry="Spring",field_maple="Spring",
dandelion="Spring,Summer,Autumn",daisy="Spring,Summer,Autumn",red_clover="Summer,Autumn",
birdsfoot_trefoil="Summer",selfheal="Summer,Autumn",knapweed="Summer,Autumn",
oxeye_daisy="Summer",field_scabious="Summer,Autumn",teasel="Summer",
cow_parsley="Spring,Summer",garlic_mustard="Spring",honeysuckle="Summer,Autumn",
ivy="Autumn",gorse="Spring,Summer,Autumn,Winter",broom="Spring,Summer"}
for id,text in pairs(flowers) do index[id].flowering=set(text) end
-- Seed-season windows are qualitative phenology, not permission to replay
-- every missed crop of seeds. Individual maturity is checked by succession.
for _,r in ipairs(records) do
r.seeding=set(r.group=="trees" and "Autumn" or "Summer,Autumn")
if r.id=="ivy" then r.seeding=set("Spring") end
if r.id=="garlic_mustard" or r.id=="cow_parsley" then r.seeding=set("Summer") end
end
for _,id in ipairs({"bilberry","blackberry","raspberry"}) do
index[id].fruiting=set(id=="blackberry" and "Summer,Autumn" or "Summer")
end
@@ -199,7 +242,7 @@ return function(hearth, api)
function B.kind(value)
local k=raw_kind(value)
if index[k] then return k end
local id,part=(k or ""):match("^([a-z]+)_([a-z]+)$")
local id,part=(k or ""):match("^([a-z_]+)_([a-z]+)$")
if index[id] and index[id].group=="trees" and
({seedling=true,young=true,mature=true,leaves=true,log=true,planks=true})[part] then return id end
end
@@ -235,8 +278,9 @@ return function(hearth, api)
end
end
function B.pollinator(value,season)
local r=record(value); return r~=nil and r.tags.pollinator==true and r.flowering[season]==true
local r=record(value); return r~=nil and r.tags.pollinator==true and r.flowering~=nil and r.flowering[season]==true
end
function B.seeding(value,season) local r=record(value); return r~=nil and r.seeding[season]==true end
function B.edible(value,season)
local r=record(value); return r~=nil and r.food==true and not r.toxic and r.fruiting[season]==true
end
@@ -285,10 +329,11 @@ return function(hearth, api)
end
end
local DAY=86400
local YEAR=365.2425*DAY -- shared with succession.frugivore; resource policy, not lifespan
local function now()
local n
if api.now then n=api.now() else n=os.time() end
if not finite(n) or n<0 or n>9007199254740991-DAY then return nil end
if not finite(n) or n<0 or n>9007199254740991-YEAR then return nil end
return math.floor(n)
end
function B.harvest(pos,player)
@@ -309,17 +354,32 @@ return function(hearth, api)
local stored=meta:get_string("botany_ready_at")
local ready=0
if stored~="" then ready=tonumber(stored) end
if not finite(ready) or ready<0 then return false,"metadata" end
if not finite(ready) or ready<0 or ready>9007199254740991 then return false,"metadata" end
if ready>time then return false,"cooldown" end
local pollen_stored=meta:get_string("succ_pollinated_until")
local pollen=pollen_stored=="" and 0 or tonumber(pollen_stored)
-- Match succession.pollinate's exact expiry and one-day future bound.
if not finite(pollen) or pollen<0 or pollen>time+DAY then return false,"metadata" end
local amount=2+(pollen>time and 1 or 0)
local inv=player:get_inventory()
if not inv or not inv:room_for_item("main","hearth:berries 2") then return false,"inventory" end
-- Lock before inventory callbacks: reentrant harvest cannot duplicate fruit.
meta:set_string("botany_ready_at",tostring(time+DAY))
local leftover=inv:add_item("main","hearth:berries 2")
if not inv then return false,"inventory" end
-- Reserve both budgets before even room_for_item: callbacks may reenter.
-- Pollination modifies an existing standing crop; it never creates fruit
-- on an empty/cooling bush and is a boolean bonus, not doubled inventory.
meta:set_string("botany_ready_at",tostring(time+YEAR))
meta:set_string("succ_pollinated_until","0")
local function rollback()
meta:set_string("botany_ready_at",stored)
meta:set_string("succ_pollinated_until",pollen_stored)
end
local item="hearth:berries "..amount
if not inv:room_for_item("main",item) then rollback(); return false,"inventory" end
local leftover=inv:add_item("main",item)
local count=leftover and leftover:get_count()
if not finite(count) or count<0 or count>2 then return false,"inventory" end
if count==2 then meta:set_string("botany_ready_at",stored); return false,"inventory" end
return true,2-count
-- An invalid delivery result cannot safely be retried: keep the lock.
if not finite(count) or count%1~=0 or count<0 or count>amount then return false,"inventory" end
if count==amount then rollback(); return false,"inventory" end
return true,amount-count
end
local registered=false
function B.register_nodes(register_node)
+17 -12
View File
@@ -3,19 +3,19 @@ return function(hearth, api)
local C = {VERSION = 1, MAX_JSON = 2048, FORM = "hearth:character"}
local key = "hearth:appearance"
local options = {
body = {"compact", "balanced", "broad"},
body = {"compact", "lean", "balanced", "broad", "sturdy"},
skin = {"porcelain", "sand", "honey", "olive", "umber", "ebony"},
face = {"calm", "angular", "soft", "weathered"},
face = {"calm", "angular", "soft", "weathered", "lined"},
eyes = {"hazel", "brown", "green", "blue", "grey"},
hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none"},
hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none", "curly", "topknot", "undercut"},
hairColor = {"ash", "flax", "chestnut", "auburn", "black", "silver"},
beard = {"none", "stubble", "moustache", "short_beard", "full_beard"},
garment = {"tunic", "workshirt", "jerkin", "coat"},
beard = {"none", "stubble", "moustache", "short_beard", "full_beard", "goatee", "braided_beard"},
garment = {"tunic", "workshirt", "jerkin", "coat", "hooded_cloak", "travel_coat", "wool_mantle"},
cloth = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
trousers = {"earth", "slate", "moss", "charcoal"}, boots = {"walnut", "black", "tan"},
accent = {"none", "scarf", "apron", "sash", "vest"},
accent = {"none", "scarf", "apron", "sash", "vest", "satchel", "tool_belt", "pauldrons", "hood"},
accentColor = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
detail = {"none", "freckles", "scar", "freckles_scar"},
detail = {"none", "freckles", "scar", "freckles_scar", "birthmark"},
}
local order = {"body", "skin", "face", "eyes", "hair", "hairColor", "beard", "garment",
"cloth", "trousers", "boots", "accent", "accentColor", "detail"}
@@ -83,8 +83,8 @@ return function(hearth, api)
local function name(stem) return "hearth_char_" .. stem .. ".png" end
local texture = table.concat({name("skin_" .. p.skin), name("face_" .. p.face), name("eyes_" .. p.eyes),
name("garment_" .. p.garment .. "_" .. p.cloth), name("trousers_" .. p.trousers), name("boots_" .. p.boots),
name("accent_" .. p.accent .. "_" .. p.accentColor), name("hair_" .. p.hair .. "_" .. p.hairColor),
name("beard_" .. p.beard .. "_" .. p.hairColor), name("detail_" .. p.detail)}, "^")
name("hair_" .. p.hair .. "_" .. p.hairColor), name("beard_" .. p.beard .. "_" .. p.hairColor),
name("accent_" .. p.accent .. "_" .. p.accentColor), name("detail_" .. p.detail)}, "^")
return "hearth_person" .. (p.body == "balanced" and "" or "_" .. p.body) .. ".b3d", texture
end
local function publish(player, value)
@@ -120,7 +120,12 @@ return function(hearth, api)
hair = "Hair style", hairColor = "Hair & facial hair color", beard = "Facial hair", garment = "Garment",
cloth = "Cloth color", trousers = "Trousers", boots = "Boot leather", accent = "Accent",
accentColor = "Accent color", detail = "Face details"}
local names = {sidepart = "Side part", workshirt = "Work shirt", freckles_scar = "Freckles and scar"}
local names = {sidepart = "Side part", workshirt = "Work shirt", freckles_scar = "Freckles and scar",
hooded_cloak = "Hooded cloak", travel_coat = "Travel coat", wool_mantle = "Wool mantle",
tool_belt = "Tool belt", braided_beard = "Braided beard", body="Body shape",
compact="Compact", lean="Lean", balanced="Balanced", broad="Broad", sturdy="Sturdy",
curly="Curly", topknot="Topknot", undercut="Undercut", goatee="Goatee",
birthmark="Birthmark", pauldrons="Shoulder guards"}
local function title(id)
return names[id] or (id:gsub("_", " "):gsub("^%l", string.upper))
end
@@ -146,7 +151,7 @@ return function(hearth, api)
"model[0.65,1.65;5.1,6.4;traveler;" .. esc(mesh) .. ";" .. esc(texture)
.. ";0," .. st.yaw .. ";true;true;0,19;12]",
"button[0.7,8.25;1.55,0.6;front;Front]button[2.4,8.25;1.55,0.6;profile;Profile]button[4.1,8.25;1.55,0.6;back;Back]",
"tooltip[traveler;Drag to turn the preview. Only this model rotates.]"}
"tooltip[traveler;" .. esc("Drag to turn the preview. Only this model rotates.") .. "]"}
for i, tab in ipairs(tabs) do
f[#f+1] = "button[" .. (6.2 + (i-1)*1.8) .. ",1.65;1.7,0.65;tab_" .. i .. ";" .. tab[1] .. "]"
end
@@ -184,7 +189,7 @@ return function(hearth, api)
for k, v in pairs(fields) do
count = count + 1; if count > 20 then return false end
if visible[k] then
local n = type(v) == "string" and #v <= 1 and v:match("^[1-9]$") and tonumber(v)
local n = type(v) == "string" and #v <= 2 and v:match("^[1-9][0-9]?$") and tonumber(v)
if not n or not options[visible[k]][n] then return false end
draft[visible[k]] = options[visible[k]][n]
elseif not controls[k] or (type(v) ~= "string" and type(v) ~= "boolean")
+7
View File
@@ -25,6 +25,8 @@ local api = setmetatable({
end,
}, {__index = core})
local modpath = api.get_modpath("hearth")
-- Plain policy namespace, available before any factory captures calendar APIs.
hearth.lifecycle = dofile(modpath .. "/lifecycle.lua")
local construction = dofile(modpath .. "/construction.lua")(hearth, api)
local seasons = dofile(modpath .. "/seasons.lua")(hearth, api)
hearth.seasons = seasons
@@ -796,4 +798,9 @@ botany.register_nodes(register_node)
-- Species leaves and canonical ecological cubes/sprites must precede variants.
seasons.register()
-- Creatura's MIT-licensed boid and ambient-spawn APIs are optional at runtime;
-- the managed package pins and bundles the ContentDB 0.2.10 release.
if type(creatura) == "table" then hearth.creatura = creatura end
hearth.wildlife = dofile(modpath .. "/wildlife.lua")(hearth, api)
-- Wildlife owns factory loading; publish that exact catalogue, not a second one.
hearth.fauna = hearth.wildlife.fauna
+33 -11
View File
@@ -1,6 +1,6 @@
-- Species/niche policy only. Succession owns the one fixed-eight scheduler.
return function(hearth, api)
local D = {species_order={"oak","birch","pine","willow","alder","beech","aspen","rowan","hazel","spruce"},
local D = {species_order={"oak","birch","pine","willow","alder","beech","aspen","rowan","hazel","spruce","crab_apple","wild_cherry","field_maple"},
plants={"clover","nettle","heather","bluebell","sedge","bulrush","moss","lichen"},
grounds={grass=true,soil=true,forest_floor=true,podzol=true,alluvium=true,peat=true,alpine_turf=true},
rocks={stone=true,granite=true,basalt=true,limestone=true,slate=true,sandstone=true,scree=true}}
@@ -14,19 +14,23 @@ return function(hearth, api)
for _, plant in ipairs(D.plants) do flora[plant]=true end
function D.tree_info(value)
local k=kind(value)
local s,stage=(k or ""):match("^([a-z]+)_([a-z]+)$")
local s,stage=(k or ""):match("^([a-z_]+)_([a-z]+)$")
if species[s] and ({seedling=true,young=true,mature=true})[stage] then return s,stage end
end
function D.family(value)
local k=kind(value)
local s,stage=D.tree_info(k)
if s then return ({seedling="tree_seedling",young="young_tree",mature="mature_tree"})[stage] end
local wood,part=(k or ""):match("^([a-z]+)_(leaves)$")
local wood,part=(k or ""):match("^([a-z_]+)_(leaves)$")
if species[wood] then return part end
wood,part=(k or ""):match("^([a-z]+)_(log)$")
wood,part=(k or ""):match("^([a-z_]+)_(log)$")
return species[wood] and part or k
end
function D.is_worldgen_flora(value) return flora[kind(value)]==true or D.tree_info(value)~=nil end
function D.is_worldgen_flora(value)
return flora[kind(value)]==true or D.tree_info(value)~=nil
or (hearth.botany and hearth.botany.kind(value)~=nil and D.family(value)~="log" and D.family(value)~="leaves"
and not (kind(value) or ""):match("_planks$")) or false
end
function D.is_ground(value) return D.grounds[kind(value)]==true end
function D.is_rock(value) return D.rocks[kind(value)]==true end
function D.supports_ground(value, support)
@@ -39,7 +43,9 @@ return function(hearth, api)
alder={alluvium=true,peat=true,forest_floor=true},
beech={grass=true,soil=true,forest_floor=true}, aspen={grass=true,forest_floor=true,alluvium=true},
rowan={grass=true,forest_floor=true,podzol=true,alpine_turf=true},
hazel={grass=true,soil=true,forest_floor=true}, spruce={podzol=true,forest_floor=true,alpine_turf=true}}
hazel={grass=true,soil=true,forest_floor=true}, spruce={podzol=true,forest_floor=true,alpine_turf=true},
crab_apple={grass=true,soil=true,forest_floor=true},wild_cherry={grass=true,soil=true,forest_floor=true},
field_maple={grass=true,soil=true,forest_floor=true}}
function D.supports_species(s, support)
if not species[s] then return false end
local b=hearth.biomes
@@ -86,9 +92,9 @@ return function(hearth, api)
choices=(h.ground=="peat" or (p.x+p.z)%2~=0) and {"alder","willow"} or {"willow","alder"}
elseif h.ground=="forest_floor" and (h.canopy or 0)>=0.5 then choices={"oak","birch"}
else choices={"birch","oak"} end
-- Retain the old pioneer niches at the origin while admitting all ten
-- Retain the old pioneer niches at the origin while admitting all thirteen
-- species deterministically; no random reroll or extra world scan.
local extra=({"beech","aspen","rowan","hazel","spruce"})[math.abs(p.x*7+p.z*11)%10]
local extra=({"beech","aspen","rowan","hazel","spruce","crab_apple","wild_cherry","field_maple"})[math.abs(p.x*7+p.z*11)%13]
if extra and D.suitable(extra.."_seedling",h) then return extra.."_seedling" end
for _,s in ipairs(choices) do if D.suitable(s.."_seedling",h) then return s.."_seedling" end end
end
@@ -150,15 +156,24 @@ return function(hearth, api)
local current=api.get_node_or_nil(p)
if not current or (n and current.name~=n.name) then return false end
n=current
-- Only fallen logs join the existing flora adoption policy; generic
-- litter/fungi and standing log/leaves trees are not newly adopted.
if not n or not (D.is_worldgen_flora(n) or n.name=="hearth:deadwood") or not unmarked(p) then return false end
local root_species=kind(n) and kind(n):match("^([a-z_]+)_log$")
local block_root=(species[root_species] or kind(n)=="log") and hearth.botany~=nil
if not n or not (D.is_worldgen_flora(n) or block_root or n.name=="hearth:deadwood") or not unmarked(p) then return false end
local below={x=p.x,y=p.y-1,z=p.z}
local support=api.get_node_or_nil(below)
if not support or not D.supports_ground(n,support) or not unmarked(below) then return false end
if block_root and not D.is_ground(support) then return false end
local m=api.get_meta(p)
if m:get_string("succ_natural")=="1" then return false end
m:set_string("succ_natural","1")
if block_root then
m:set_string("succ_component",p.x..":"..p.y..":"..p.z)
m:set_string("succ_worldgen_stage","mature")
m:set_string("succ_species",root_species or "generic")
end
-- Succession adopts a known displayed stage on its first bounded visit;
-- bootstrap does not backdate an invented historical birthday.
if block_root or (hearth.botany and hearth.botany.kind(n)) then m:set_string("succ_pending_origin","worldgen_established") end
return true
end
local seasons=hearth.seasons
@@ -220,7 +235,14 @@ return function(hearth, api)
groups={snappy=3,flora=1,flammable=2,hearth_ecosystem=1},drop="",after_place_node=placed})
end
local names={"hearth:deadwood"}
if hearth.botany then
names[#names+1]="hearth:log"
for _,s in ipairs(D.species_order) do names[#names+1]="hearth:"..s.."_log" end
end
for k in pairs(flora) do names[#names+1]="hearth:"..k end
if hearth.botany then for _,r in ipairs(hearth.botany.list()) do
if r.group~="trees" and not flora[r.id] then names[#names+1]=r.node end
end end
for _,s in ipairs(D.species_order) do for _,stage in ipairs({"seedling","young","mature"}) do names[#names+1]="hearth:"..s.."_"..stage end end
if seasons then for _,n in ipairs(seasons.names()) do if D.is_worldgen_flora(n) then names[#names+1]=n end end end
api.register_lbm({name="hearth:diversity_natural_flora",label="Mark unmodified species worldgen flora",
+77 -37
View File
@@ -1,7 +1,8 @@
-- Bounded, restart-safe ecology for perennial forage, saplings and seasonal crops.
return function(hearth, api, construction)
local DAY, MAX_CATCHUP = 86400, 30 * 86400
local CROP_STEP, CROP_MATURE_HOURS = 21600, 60
local DAY, YEAR = 86400, 365.2425*86400
-- Unidentified game grain, not a researched named cereal cultivar.
local CROP_STEP, CROP_MATURE_HOURS, CROP_SAMPLES = 21600, 120*24, 366
local function finite(value)
value = tonumber(value)
if not value or value ~= value or value == math.huge or value == -math.huge then return nil end
@@ -28,25 +29,26 @@ return function(hearth, api, construction)
planted, now = finite(planted), finite(now)
if not planted or not now or planted < 0 or now < 0 then return 0 end
planted, now = math.floor(planted), math.floor(now)
now = math.min(math.floor(now), planted + MAX_CATCHUP)
if planted <= 0 or now <= planted then return 0 end
local progress, cursor = 0, planted
while cursor < now and progress < CROP_MATURE_HOURS do
local finish = math.min(now, cursor + CROP_STEP)
local sample = cursor + math.floor((finish - cursor) / 2)
-- Bounded midpoint quadrature covers the ENTIRE calendar interval. Long
-- gaps coarsen samples, never silently discard all age after day thirty.
local duration=now-planted
local samples=math.min(CROP_SAMPLES,math.max(1,math.ceil(duration/DAY)))
local progress=0
for i=1,samples do
local sample=planted+math.floor(duration*(i-.5)/samples)
local climate = hearth.climate_at and hearth.climate_at(sample)
local temperature = finite(climate and climate.temperature) or 12
local rate = temperature < 3 and 0 or (temperature < 10 and 0.5 or 1)
progress = progress + (finish - cursor) / 3600 * rate
cursor = finish
progress = progress + duration/samples/3600*rate
end
return math.min(CROP_MATURE_HOURS, progress)
return math.min(CROP_MATURE_HOURS,progress,duration/3600)
end
local function crop_stage(planted, now)
local growth = climate_progress(planted, now)
if growth >= CROP_MATURE_HOURS then return 4 end
if growth >= 36 then return 3 end
if growth >= 12 then return 2 end
if growth >= 72*24 then return 3 end
if growth >= 24*24 then return 2 end
return 1
end
local function update_crop(pos)
@@ -54,8 +56,14 @@ return function(hearth, api, construction)
if not current or not current.name:match("^hearth:crop_[1234]$") then return false end
local meta, now = api.get_meta(pos), safe_now()
local planted = tonumber(meta:get_string("planted_at")) or 0
if planted <= 0 or planted > now then meta:set_string("planted_at", tostring(now)); planted = now end
if planted <= 0 or planted > now then return true end
local observed=finite(meta:get_string("crop_last_observed_at")) or planted
if observed>now then return true end
meta:set_string("crop_last_observed_at",tostring(now))
local stage = crop_stage(planted, now)
-- Quadrature refinement is approximate, so an established stage never
-- regresses when a different sample partition straddles a cold front.
stage=math.max(stage,tonumber(current.name:match("(%d)$")))
local target = "hearth:crop_" .. stage
if current.name ~= target then api.swap_node(pos, {name = target, param2 = current.param2}) end
return stage < 4
@@ -98,28 +106,37 @@ return function(hearth, api, construction)
api.get_meta(pos):set_string("player_placed", "1")
end
local function harvest_berries(pos, player)
if not construction.allowed(pos, player, "use") then return end
if not construction.allowed(pos, player, "use") then return false end
local n = node(pos)
if not n then return end
if not n or (n.name~="hearth:berry_bush" and n.name~="hearth:berry_bush_bare") then return false end
if n.name == "hearth:berry_bush_bare" then
hearth.notify(player, "The berry bush is regrowing; return after one real day.")
return
hearth.notify(player, "The berry bush has spent this crop; check a future Summer after its annual rest.")
return false
end
local meta,now=api.get_meta(pos),safe_now()
local previous=meta:get_string("ready_at")
local due=previous=="" and 0 or finite(previous)
if not due or due<0 or due>now or now>9007199254740991-YEAR then return false end
local inv = player:get_inventory()
local output = ItemStack("hearth:berries 3")
if not inv or not inv:room_for_item("main", output) then
hearth.notify(player, "Make inventory space before picking these berries.")
return
return false
end
local requested = output:get_count()
-- Shared durable reservation before inventory callbacks, including
-- reentrant picking. A rejected transfer restores exactly the old field.
local ready = now + YEAR
meta:set_string("ready_at",tostring(ready))
local leftover = inv:add_item("main", output)
if not leftover or leftover:get_count() >= requested then return end
api.set_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2})
local ready = safe_now() + DAY
api.get_meta(pos):set_string("ready_at", tostring(ready))
api.get_meta(pos):set_string("infotext", "Berry bush: regrowing (one real day)")
local count=leftover and finite(leftover:get_count())
if not count or count<0 or count>requested then return false end -- uncertain delivery stays locked
if count==requested then meta:set_string("ready_at",previous); return false end
api.swap_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2})
meta:set_string("infotext", "Berry bush: annual rest; next eligible Summer")
api.get_node_timer(pos):start(DAY)
if hearth.invalidate_environment then hearth.invalidate_environment(pos) end
return true
end
local function harvest_seasonal(pos, player)
if not construction.allowed(pos, player, "use") then return false end
@@ -129,25 +146,30 @@ return function(hearth, api, construction)
local item = ({spring_greens_patch="hearth:spring_greens", autumn_mushrooms_patch="hearth:autumn_mushrooms"})[kind]
if not item then return false end
local now, meta = safe_now(), api.get_meta(pos)
local ready = finite(meta:get_string("succ_harvest_ready_at")) or 0
if not seasons.available(kind, now) or ready > now then
local previous=meta:get_string("succ_harvest_ready_at")
local ready = previous=="" and 0 or finite(previous)
if not ready or ready<0 or not seasons.available(kind, now) or ready > now then
hearth.notify(player, "This patch rests outside its season or until one real day after picking.")
return false
end
local inv, output = player:get_inventory(), ItemStack(item)
if not inv or not inv:room_for_item("main", output) then return false end
-- One item only: no partial multi-item harvest can lose or duplicate food.
local leftover = inv:add_item("main", output)
if not leftover or leftover:get_count() ~= 0 then return false end
meta:set_string("succ_harvest_ready_at", tostring(now + DAY))
local leftover = inv:add_item("main", output)
local count=leftover and finite(leftover:get_count())
if count==1 then meta:set_string("succ_harvest_ready_at",previous); return false end
if count~=0 then return false end
return true
end
local function refresh_bush(pos)
local n = node(pos)
if not n or n.name ~= "hearth:berry_bush_bare" then return false end
local meta, now = api.get_meta(pos), safe_now()
local ready = tonumber(meta:get_string("ready_at")) or 0
if ready <= now then
local ready = finite(meta:get_string("ready_at"))
if not ready or ready<0 then return false end
local climate=hearth.climate_at and hearth.climate_at(now)
if ready <= now and climate and climate.season=="Summer" then
api.swap_node(pos, {name = "hearth:berry_bush", param2 = n.param2})
meta:set_string("ready_at", "0"); meta:set_string("infotext", "")
api.get_node_timer(pos):stop()
@@ -158,8 +180,7 @@ return function(hearth, api, construction)
local grow_sapling
local function reconcile_sapling(pos)
if grow_sapling(pos) then
local planted = tonumber(api.get_meta(pos):get_string("planted_at")) or safe_now()
api.get_node_timer(pos):start(math.max(60, math.min(DAY, planted + 3 * DAY - safe_now())))
api.get_node_timer(pos):start(DAY)
end
end
local function reconcile_bush(pos)
@@ -187,17 +208,36 @@ return function(hearth, api, construction)
if not n or n.name ~= "hearth:sapling" then return false end
local meta, now = api.get_meta(pos), safe_now()
local planted = tonumber(meta:get_string("planted_at")) or 0
if planted <= 0 or planted > now then meta:set_string("planted_at", tostring(now)); planted = now end
if planted <= 0 or planted > now then return true end
local previous=finite(meta:get_string("succ_last_observed_at")) or planted
if previous>now then return true end
meta:set_string("succ_birth_at",tostring(planted))
meta:set_string("succ_last_observed_at",tostring(now))
meta:set_string("succ_lifecycle_version","1")
meta:set_string("succ_lifecycle_origin","player_planted")
meta:set_string("succ_lifecycle_profile","generic_tree")
meta:set_string("succ_lifecycle_policy","generic_1y_young_10y_mature_unknown_lifespan")
local owner = meta:get_string("planter")
if now - planted < 3 * DAY or not tree_clear(pos, owner) then return true end
for y = 0, 3 do api.set_node({x=pos.x,y=pos.y+y,z=pos.z},{name="hearth:log"}) end
if now - planted < YEAR or not tree_clear(pos, owner) then return true end
local function plant_wood(q,name)
api.set_node(q,{name=name}); api.get_meta(q):set_string("player_placed","1")
end
for y = 0, 3 do plant_wood({x=pos.x,y=pos.y+y,z=pos.z},"hearth:log") end
for y = 3, 5 do for x = -2, 2 do for z = -2, 2 do
local radius = y == 5 and 1 or 2
if math.abs(x) + math.abs(z) <= radius and not (x == 0 and z == 0 and y < 5) then
local p = {x=pos.x+x,y=pos.y+y,z=pos.z+z}
if api.get_node(p).name == "air" then api.set_node(p, {name="hearth:leaves"}) end
if api.get_node(p).name == "air" then plant_wood(p,"hearth:leaves") end
end
end end end
-- These are player trees, not a newly generated natural forest. Mark
-- every written cell so later worldgen adoption cannot erase provenance.
local root=api.get_meta(pos)
root:set_string("planted_at",tostring(planted)); root:set_string("planter",owner)
root:set_string("succ_birth_at",tostring(planted)); root:set_string("succ_last_observed_at",tostring(now))
root:set_string("succ_lifecycle_version","1"); root:set_string("succ_lifecycle_origin","player_planted")
root:set_string("succ_lifecycle_profile","generic_tree")
root:set_string("succ_lifecycle_policy","generic_1y_young_10y_mature_unknown_lifespan")
return false
end
local function plant_sapling(stack, player, pointed)
@@ -207,7 +247,7 @@ return function(hearth, api, construction)
api.set_node(pos, {name = "hearth:sapling"})
local meta = api.get_meta(pos)
meta:set_string("planted_at", tostring(safe_now())); meta:set_string("planter", player:get_player_name())
api.get_node_timer(pos):start(3 * DAY)
api.get_node_timer(pos):start(DAY)
stack:take_item(1)
return stack
end
@@ -226,5 +266,5 @@ return function(hearth, api, construction)
refresh_bush = refresh_bush, reconcile_bush = reconcile_bush,
reconcile_crop = reconcile_crop, plant_sapling = plant_sapling,
grow_sapling = grow_sapling, reconcile_sapling = reconcile_sapling,
MAX_CATCHUP = MAX_CATCHUP, CROP_MATURE_HOURS = CROP_MATURE_HOURS}
CROP_SAMPLE_LIMIT = CROP_SAMPLES, CROP_MATURE_DAYS = 120, CROP_MATURE_HOURS = CROP_MATURE_HOURS}
end
+115
View File
@@ -0,0 +1,115 @@
-- Read-only catalogue factory. Biology comes from lifecycle, never game ticks.
return function(hearth, api)
local F={VERSION=1,SECONDS_PER_DAY=86400}
local function copy(t)
if type(t)~="table" then return t end
local r={}; for k,v in pairs(t) do r[k]=copy(v) end; return r
end
local function set(s) local r={}; for id in s:gmatch("%S+") do r[id]=true end; return r end
local lifecycle=hearth.lifecycle
if not lifecycle and api.get_modpath then lifecycle=dofile(api.get_modpath("hearth").."/lifecycle.lua") end
-- Bind-pose bounds measured from faunaBounds(), unitsPerNode=10. Runtime
-- collision uses a conservative yaw-independent envelope plus animation margin.
-- id, Latin identity (false for legacy deer), medium, food, activity, cap,
-- speed (presentation policy, nodes/s), width radius, height, rear, front.
local rows={
{"hare","Lepus europaeus","land","browse","night",6,1.3,.1375,.814,.3415,.37004},
{"deer",false,"land","browse","crepuscular",3,1,.31625,1.5947,.683,.65081},
{"fox","Vulpes vulpes","land","predator fruit","night",3,1.2,.15,.6856,.8624,.5028},
{"rabbit","Oryctolagus cuniculus","land","browse","crepuscular",6,1,.154,.5509,.25954,.31371},
{"red_squirrel","Sciurus vulgaris","land","seed fruit","day",4,.9,.075,.5924,.46744,.24266},
{"bank_vole","Myodes glareolus","land","browse seed fruit","any",6,.55,.07,.196,.17039,.17507},
{"wood_mouse","Apodemus sylvaticus","land","seed fruit","night",6,.6,.06,.2386,.31045,.162505},
{"hedgehog","Erinaceus europaeus","land","predator","night",3,.35,.11,.25,.21173,.23755},
{"badger","Meles meles","land","predator fruit","night",2,.65,.19,.4924,.45078,.475885},
{"roe_deer","Capreolus capreolus","land","browse","crepuscular",3,1,.22375,1.2285,.49176,.48443},
{"stoat","Mustela erminea","land","predator","any",2,1.1,.06,.2462,.4514625,.269535},
{"weasel","Mustela nivalis","land","predator","any",2,.85,.0425,.1808,.26846,.19111},
{"otter","Lutra lutra","amphibian","predator","night",2,.9,.125,.3884,.70711,.39778},
{"robin","Erithacus rubecula","fly","predator fruit seed","day",4,.8,.1646,.3721,.21895,.173875},
{"blackbird","Turdus merula","fly","predator fruit","day",4,.8,.1844,.4175,.30653,.2014},
{"blue_tit","Cyanistes caeruleus","fly","seed fruit","day",4,.9,.125,.3167,.17516,.13705},
{"barn_owl","Tyto alba","fly","predator","night",2,1.2,.2834,.6434,.2736875,.275},
{"mallard","Anas platyrhynchos","amphibian","seed browse fruit","day",4,.65,.2735,.4704,.4379,.2555},
{"common_frog","Rana temporaria","amphibian","predator","any",4,.3,.099,.1475,.15026,.22272},
{"smooth_newt","Lissotriton vulgaris","amphibian","predator","night",4,.16,.04,.1014,.3423125,.162945},
{"brown_trout","Salmo trutta","swim","predator","any",4,.65,.1045,.3045,.3552,.24},
{"bumblebee","Bombus terrestris","fly","nectar","day",6,.65,.144,.1225,.065,.1064},
{"earthworm","Lumbricus terrestris","invertebrate","litter","night",8,20/3600,.02,.04,.191266666666667,.191266666666667},
}
local rest_policy={
day={kind="solar_day",sleep_below=-6,winddown_below=6},
night={kind="solar_night",sleep_above=6,winddown_above=-6},
crepuscular={kind="twilight",active_between={-8,8},rest_hours={11,15}},
any={kind="flexible",rest_hours={12,14},winddown_hours={11.5,14.5}},
}
local winter_dormant=set("hedgehog common_frog smooth_newt")
local prey={fox="hare rabbit bank_vole wood_mouse red_squirrel earthworm common_frog",
stoat="rabbit bank_vole wood_mouse",weasel="bank_vole wood_mouse robin blue_tit",
barn_owl="bank_vole wood_mouse",badger="earthworm bank_vole wood_mouse hedgehog",
hedgehog="earthworm",otter="brown_trout common_frog smooth_newt",
robin="earthworm",blackbird="earthworm",common_frog="earthworm",smooth_newt="earthworm",
-- No imaginary mayfly/larva population and no adult-trout cannibalism.
brown_trout=""}
local index,order={},{}
local clips={idle={0,19},locomotion={20,39},feed={40,59},flight_swim={60,79},rest={80,99}}
for _,v in ipairs(rows) do
local id=v[1]; local profile=lifecycle and lifecycle.animal(id) or false
local url="https://en.wikipedia.org/wiki/"..(v[2] and v[2]:gsub(" ","_") or "Deer")
local r={id=id,kind="animal",entity="hearth:"..id,common=id:gsub("_"," "),latin=v[2],
medium=v[3],foods=set(v[4]),activity=v[5],cap=v[6],speed=v[7],
bounds={min={-v[8],0,-v[10]},max={v[8],v[9],v[11]}},
mesh="hearth_fauna_"..id..".b3d",texture="hearth_fauna_"..id..".png",
clips=copy(clips),fps=24,frames=100,eye_height=math.max(.15,v[9]*.8),
prey=set(prey[id] or ""),profile=profile,source_url=url,wikipedia_url=url,
sleep=copy(rest_policy[v[5]] or rest_policy.crepuscular),
winter_dormant=winter_dormant[id]==true,
sources={url},confidence=id=="deer" and "unverified_identity" or "species_account",
-- Numeric feeding cadence/reserve is a coarse energy accounting policy,
-- NOT a claimed species metabolic/starvation measurement.
feed_seconds=(id=="bumblebee" and 300 or (prey[id] and 7200 or 3600)),
reserve_seconds=2*86400,starvation_days=false}
if profile and profile.source_url then r.sources[#r.sources+1]=profile.source_url end
if id=="rabbit" then
r.starvation_days=8; r.starvation_source=url.."#Diet"
end
index[id]=r; order[#order+1]=id
end
function F.kind(value)
local n=type(value)=="table" and value.name or value
if type(n)~="string" or #n>80 then return nil end
n=n:gsub("^hearth:",""); return index[n] and n or nil
end
function F.get(value) return copy(index[F.kind(value)]) end
function F.list() local out={}; for _,id in ipairs(order) do out[#out+1]=copy(index[id]) end; return out end
F.catalog=F.list
function F.source(value) local r=index[F.kind(value)]; return r and copy(r.sources) end
function F.phase(id,born,now)
local r=index[F.kind(id)]
return r and r.profile and lifecycle and lifecycle.phase(r.profile,born,now) or nil
end
function F.breeding_window(id,month)
local r=index[F.kind(id)]
return r and r.profile and lifecycle and lifecycle.breeding_window(r.profile,month) or nil
end
function F.can_breed(id,born,now,sex,current_season)
local r=index[F.kind(id)]; local p=r and r.profile
if not p or p.reproduction_enabled~=true then return false,"incomplete_profile" end
if sex~="female" and sex~="male" then return false,"sex" end
local development=p.reproduction=="egg" and "incubation_days" or "gestation_days"
for _,field in ipairs({"maturity_days",development,"juvenile_days","birth_count"}) do
local t=p[field]
if type(t)~="table" or t.confidence~="source_reported" or not t.source_url then return false,"incomplete_profile" end
end
local phase=F.phase(id,born,now)
if not phase then return false,"age" end
if current_season==nil and hearth.climate_at then
local c=hearth.climate_at(now); current_season=c and c.season
end
-- Climate's season already accounts for the configured hemisphere. Do
-- not intersect it with northern UK months (which breaks southern spring).
return phase.phase=="mature" and type(p.breeding_seasons)=="table"
and p.breeding_seasons[current_season]==true or false
end
return F
end
+269 -25
View File
@@ -1,5 +1,10 @@
local C = core
local esc = C.formspec_escape
local function tooltip(name,text)
-- Luanti 5.18 requires both tooltip colors when the optional color slots are
-- parsed. Supplying the Mocha pair also keeps high-density creator menus legible.
return "tooltip[" .. esc(name) .. ";" .. esc(text) .. ";#181825;#cdd6f4]"
end
-- eom.dev Mocha anchors: shared by every journal surface, not terrain tints.
local ink = {base="#1e1e2e", mantle="#181825", raised="#292940", border="#3c3d56",
text="#cdd6f4", muted="#a6adc8", mauve="#cba6f7", teal="#94e2d5", yellow="#f9e2af", danger="#f38ba8"}
@@ -50,11 +55,11 @@ local pages = {
{"Your homestead", "MAKE A PLACE YOURS", "A community without compulsory chores.",
"Use /claim at your homestead to protect a 25 x 25 area, extending 24 blocks up/down. Use /claim show for temporary boundary markers. Share with /claim share <player> resident (build and household use) or /claim share <player> storekeeper (storage only). /claim unshare <player> revokes access; /claim list shows members; /claim remove releases the claim and grants.\n\nPlace homestead storage inside a claim. Its inventory stays private to the owner, residents and storekeepers; non-empty storage cannot be removed. Workstations and construction require resident permissions.\n\nUse /sethome in your own safe claim; /home rechecks that position before moving you and safely falls back to the world arrival if the home is blocked or hazardous. Your inventory, food, water and wetness persist through death.\n\nLogged-out player needs pause. Hearth processes and fuel use real elapsed time; completed output remains stored until claimed. Food does not spoil in this slice. This remains a prototype, not a hardened public service."},
{"Living ecosystems", "NOTHING GROWS ALONE", "Watch a meadow change; there is no quest to complete.",
"An original succession model inspired by Dynamic Life connects light, moisture, plants and animals. The botanical catalogue has 80 named species: eight categories of ten each. Use the Botanical guide button or /botany [search] for common and scientific names, habitat, seasons, toxicity labels and roles. These are game-only notes, not a real-world forage guide.\n\nBirch pioneers open gaps; oak favors meadow/forest soils. Evergreen pine and spruce keep winter shade; deciduous oak, birch, willow, alder, beech, aspen, rowan and hazel thin in Winter. Willow favors wet alluvium/peat. Nitrogen-fixer policy can add at most two nutrient units per support across replacements/restarts while reserves are low. The legacy alder terrain profile alone is not a separate fertilizer process. Litter, fungi, deadwood and dung recycle finite nutrients. Fallen logs are ordinary sideways cubes: an axe yields one log, not hand-gathered sticks.\n\nSpring/Summer clover and flowers support pollinators; spring bluebells and autumn heather have seasonal windows. Wet sedge/bulrush, shade moss/ferns, rock lichen and nutrient-needing nettle have distinct niches. The survey counts supporting plants, not insects. Hares and deer graze natural cover; foxes pursue hares. Use /ecosystem for actual ground, light, moisture, canopy, nutrient reserve and active wildlife totals. Moisture is a bounded habitat estimate, not fully simulated hydrology, erosion or flooding.\n\nRoles are conditional policies or descriptors, not effects guaranteed for every species: parasitism and allelopathy cause no plant damage, and a resin tag is not an extraction recipe. Wild succession advances only around connected players in loaded areas. It pauses across unloading and restart, with no offline catch-up. Claims, placed plants, crops and construction are excluded from autonomous changes. Stages take active sessions, not a daily login. Crops and planted saplings keep separate growth rules."},
"An original succession model inspired by Dynamic Life connects light, moisture, plants and animals. The botanical catalogue has 100 named species in eight uneven ecological groups, not eight groups of ten. Use /botany [search] for names, habitat, seasons, toxicity labels and roles; /fauna lists 23 animal identities with sources and lifecycle status. These are game-only notes, not a real-world forage guide.\n\nBirch pioneers open gaps; oak favors meadow/forest soils. Evergreen pine and spruce keep winter shade; deciduous trees including crab apple, wild cherry and field maple thin in Winter. Willow favors wet alluvium/peat. Nitrogen-fixer policy can add at most two nutrient units per support after 90 real calendar days, across replacements/restarts while reserves are low. The legacy alder terrain profile alone is not a separate fertilizer process. Litter, fungi, deadwood and dung recycle finite nutrients. Fallen logs are ordinary sideways cubes: an axe yields one log, not hand-gathered sticks.\n\nSpring/Summer clover and flowers support pollinators; spring bluebells and autumn heather have seasonal windows. Wet sedge/bulrush, shade moss/ferns, rock lichen and nutrient-needing nettle have distinct niches. The plant survey counts supporting plants, not insects; wildlife counts include every active species. Animals use finite browse, nectar, fruit, seeds, litter or modeled prey. Use /ecosystem for observations. Moisture is a bounded habitat estimate, not fully simulated hydrology, erosion or flooding.\n\nRoles are conditional policies or descriptors: parasitism and allelopathy cause no plant damage; a resin tag is not an extraction recipe. Calendar ages include unloaded time. Ecological work advances only in loaded areas with no offline catch-up or missed-event replay. Claims, placed plants, crops and construction are excluded from autonomous changes. Named tree stages use real years to decades, not active sessions; fungi do not accelerate growth. Source-reported fields, policy fallbacks and UNKNOWN are distinct. All incomplete animal profiles disable breeding; initial established wildlife are immigration, not adult births. Unknown age and lifespan endpoints never authorize automatic death."},
{"Clock & location", "ONE WORLD. ONE CLOCK.", "A real calendar, not a daily login requirement.",
"Local play asks you to choose approximate coordinates. No location service, IP lookup or external request is used. Use /location to change them. Coordinates stay in local world storage.\n\nA community server must set its shared latitude and longitude in configuration. Remote players cannot override them. The world follows the host's real UTC clock.\n\nSun height and day length follow latitude, longitude and date. The engine's fixed sky orbit is approximated; it is not an exact astronomical renderer. The HUD uses UTC and local solar time, not civil timezone labels.\n\nClimate and rain are synthetic. Calendar-season labels use meteorological months; southern latitudes reverse them."},
{"Geology & timber", "READ THE REGIONAL MATERIALS", "Ten biomes; shared sky, different ground.",
"Temperate meadow and oak woodland favor limestone; birch forest and pine taiga favor granite. Wet forest has peat over slate; floodplain has alluvium over sandstone. Dry scrub has sandstone, alpine meadow has turf/scree over slate, rocky peaks have snow/scree over granite, and riverbank has sand/gravel over sandstone. These are ten terrain communities, not ten independent weather simulations. The climate HUD remains shared; altitude still affects exposure temperature.\n\nQuartz seams occur in granite/slate; copper in basalt/limestone/slate up to Y=32; coal in sandstone/slate/limestone up to Y=70. Deeper beds and basalt intrusions differ from surface rock. Mine with a pick; each seam drops two resource items. Three quartz craft one existing window, without a glass furnace.\n\nOne copper ore + one charcoal craft one copper charge. At a lit hearth it takes 45 real seconds and reserves 30 fuel seconds, yielding one ingot. Three copper ingots across the top row, with two sticks down the center, make one faster, longer-lasting copper pick. Iron remains a separate 60-second smelt and axe route.\n\nOak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each burn for 180 seconds, or craft four matching planks. Convert two matching species logs to two canonical logs for log + clay charcoal charges; convert species planks 1:1 to canonical planks for exact-name building recipes. One regional rock crafts four matching bricks; two matching rocks convert to two canonical stones. Put each input in separate crafting slots (shapeless); there is no single-log or single-rock canonical conversion. Fallen logs are sideways full cubes; an axe yields one canonical log, never two hand-gathered sticks. Coal burns 240 seconds; charcoal 90; sticks 45. All fuel is capped at 600 seconds. Coal is geological and nonrenewable, not a renewable fuel loop.\n\nThe broad-relief terrain preset is for fresh worlds only. Existing chunks stay untouched; new biome registrations can change unexplored old-world boundaries. Operators must explicitly choose a new world or accept those seams; never delete map metadata to force a migration."},
"Temperate meadow and oak woodland favor limestone; birch forest and pine taiga favor granite. Wet forest has peat over slate; floodplain has alluvium over sandstone. Dry scrub has sandstone, alpine meadow has turf/scree over slate, rocky peaks have snow/scree over granite, and riverbank has sand/gravel over sandstone. These are ten terrain communities, not ten independent weather simulations. The climate HUD remains shared; altitude still affects exposure temperature.\n\nQuartz seams occur in granite/slate; copper in basalt/limestone/slate up to Y=32; coal in sandstone/slate/limestone up to Y=70. Deeper beds and basalt intrusions differ from surface rock. Mine with a pick; each seam drops two resource items. Three quartz craft one existing window, without a glass furnace.\n\nOne copper ore + one charcoal craft one copper charge. At a lit hearth it takes 45 real seconds and reserves 30 fuel seconds, yielding one ingot. Three copper ingots across the top row, with two sticks down the center, make one faster, longer-lasting copper pick. Iron remains a separate 60-second smelt and axe route.\n\nOak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce, crab apple, wild cherry and field maple logs each burn for 180 seconds, or craft four matching planks. Convert two matching species logs to two canonical logs for log + clay charcoal charges; convert species planks 1:1 to canonical planks for exact-name building recipes. One regional rock crafts four matching bricks; two matching rocks convert to two canonical stones. Put each input in separate crafting slots (shapeless); there is no single-log or single-rock canonical conversion. Fallen logs are sideways full cubes; an axe yields one canonical log, never two hand-gathered sticks. Coal burns 240 seconds; charcoal 90; sticks 45. All fuel is capped at 600 seconds. Coal is geological and nonrenewable, not a renewable fuel loop.\n\nThe broad-relief terrain preset is for fresh worlds only. Existing chunks stay untouched; new biome registrations can change unexplored old-world boundaries. Operators must explicitly choose a new world or accept those seams; never delete map metadata to force a migration."},
}
local botanical_groups = {
{"trees", "Trees"}, {"shrubs", "Shrubs"}, {"grasses", "Grasses, sedges & rushes"},
@@ -68,7 +73,7 @@ end
local function botanical_summary()
local records, lines = catalogue(), {}
if #records == 0 then return "Botanical catalogue unavailable in this runtime. Use /botany when installed." end
lines[1] = "BOTANICAL CATALOGUE / " .. #records .. " named species; eight categories of ten in the full catalogue.\nUse /botany [search] for scientific names, habitat windows, seasons and ecological roles."
lines[1] = "BOTANICAL CATALOGUE / " .. #records .. " named species; eight ecological groups of uneven sizes.\nUse /botany [search] for scientific names, habitat windows, seasons and ecological roles; /fauna for 23 animal identities and lifecycle evidence."
for _, group in ipairs(botanical_groups) do
local names = {}
for _, r in ipairs(records) do
@@ -99,11 +104,12 @@ function hearth.guide(player, page)
local notes = p[4] .. (page == 6 and "\n\n" .. botanical_summary() or "")
f[#f + 1] = "textarea[3.8,2.65;9.6,5.6;;;" .. esc(notes) .. "]"
f[#f + 1] = "button[3.85,8.15;2.3,0.55;inspect_target;Inspect target]"
f[#f + 1] = "button[6.3,8.15;2.1,0.55;fauna_index;Fauna guide]"
f[#f + 1] = "button[3.85,8.85;2.3,0.65;ecosystem_survey;Ecosystem survey]"
.. "tooltip[ecosystem_survey;Read a loaded habitat patch and global active wildlife. No world changes.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("ecosystem_survey","Read a loaded habitat patch and global active wildlife. No world changes.")
f[#f + 1] = "button[6.3,8.85;2.1,0.65;botany_index;Botanical guide]"
f[#f + 1] = "button[8.55,8.85;2.1,0.65;recipe_index;Recipe index]"
.. "tooltip[recipe_index;Search ingredients, results and real-time processes.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("recipe_index","Search ingredients, results and real-time processes.")
f[#f + 1] = "button_exit[10.8,8.85;2.5,0.65;close;Back to the valley]"
C.show_formspec(player:get_player_name(), "hearth:guide", table.concat(f))
end
@@ -112,6 +118,7 @@ local recipe_outputs = {"hearth:knife", "hearth:axe", "hearth:pick", "hearth:spe
"hearth:copper_charge", "hearth:copper_pick", "hearth:log", "hearth:stone",
"hearth:oak_planks", "hearth:birch_planks", "hearth:pine_planks", "hearth:willow_planks", "hearth:alder_planks",
"hearth:beech_planks", "hearth:aspen_planks", "hearth:rowan_planks", "hearth:hazel_planks", "hearth:spruce_planks",
"hearth:crab_apple_planks", "hearth:wild_cherry_planks", "hearth:field_maple_planks",
"hearth:granite_bricks", "hearth:basalt_bricks", "hearth:limestone_bricks", "hearth:slate_bricks", "hearth:sandstone_bricks",
"hearth:firestarter", "hearth:vessel", "hearth:torch", "hearth:fire", "hearth:wall",
"hearth:roof", "hearth:foundation", "hearth:frame", "hearth:stairs", "hearth:door",
@@ -127,8 +134,8 @@ local process_notes = {
{"Smelting", "One iron ore/charcoal charge + lit hearth: 60 real seconds; 30 fuel seconds reserved at start. Copper uses its separate 45-second charge."},
{"Copper smelting", "One hearth:copper_charge (one hearth:copper_ore + one hearth:charcoal) + lit hearth: 45 real seconds; 30 fuel seconds reserved at start; one hearth:copper_ingot output. Requires at least 75 fuel seconds to queue. Collect empty-handed; a full inventory leaves the ingot waiting. Copper pick: three ingots across the top and two central sticks."},
{"Regional geology", "Quartz in granite/slate: three hearth:quartz craft one window; the reed/plank route remains. Copper ore in basalt/limestone/slate; coal in sandstone/slate/limestone. Each seam drops two items. One granite, basalt, limestone, slate or sandstone crafts four matching bricks. Convert two matching rocks to two canonical stones in separate crafting slots (shapeless); no single-rock canonical conversion."},
{"Species timber", "One oak, birch, pine, willow, alder, beech, aspen, rowan, hazel or spruce log crafts four matching planks. Convert two matching species logs in separate crafting slots (shapeless) to two canonical hearth:log; no single-log canonical conversion. Species planks convert 1:1 to canonical hearth:planks. Charcoal charges accept only canonical log + clay: convert species logs in matching pairs first, not four planks back into logs."},
{"Finite nitrogen", "Nitrogen-fixer policy, including clover and alder in the botanical runtime, can add at most two nutrient units per support across replacements and restarts while the reserve is low. The legacy alder terrain profile alone is not a fertilizer process. Nutrient reserve is bounded at eight; litter, deadwood, fungi and dung recycle finite material."},
{"Species timber", "One oak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce, crab_apple, wild_cherry or field_maple log crafts four matching planks. Convert two matching species logs in separate crafting slots (shapeless) to two canonical hearth:log; no single-log canonical conversion. Species planks convert 1:1 to canonical hearth:planks. Charcoal charges accept only canonical log + clay: convert species logs in matching pairs first, not four planks back into logs."},
{"Finite nitrogen", "After 90 real calendar days, nitrogen-fixer policy, including clover and alder in the botanical runtime, can add at most two nutrient units per support across replacements and restarts while the reserve is low and conditions are warm. This cadence is game policy, not a measured species rate. The legacy alder terrain profile alone is not a fertilizer process. Nutrient reserve is bounded at eight; litter, deadwood, fungi and dung recycle finite material."},
{"Fallen logs", "Ordinary full-cube fallen logs lie sideways. Use an axe to harvest one canonical hearth:log; hands do not yield sticks. They also support decomposer habitat. Player-placed and claimed material is excluded from autonomous succession."},
{"Pottery firing", "One shaped clay-jar charge + lit hearth: 120 real seconds; 30 fuel seconds reserved at start."},
{"Food drying", "One cooked-food/reed charge + lit hearth: 300 real seconds; 30 fuel seconds reserved at start."},
@@ -136,11 +143,11 @@ local process_notes = {
{"Fish preparation", "Roast one raw fish at a lit hearth for 8 real seconds; reserve 20 fuel seconds. Combine cooked fish with reeds into a drying charge, then dry for 300 seconds with 30 fuel seconds reserved. Dried fish is shelf-stable."},
{"Spring fish run", "A trap finishing during spring returns two fish; other seasons return one. The catch is determined by its scheduled deadline, even if you are away."},
{"Light a hearth", "Fuel first, then use a flint firestarter. Each starter has 20 successful ignitions."},
{"Fire fuel", "Each stick adds 45 real seconds; canonical, oak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each add 180; charcoal adds 90; coal adds 240 seconds. Stored reserve is capped at 600; a fuel item that would exceed it is not consumed. Planks and buildings are not direct fuel. Coal is geological and nonrenewable; it cannot be crafted or grown."},
{"Fire fuel", "Each stick adds 45 real seconds; canonical, oak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce, crab_apple, wild_cherry and field_maple logs each add 180; charcoal adds 90; coal adds 240 seconds. Stored reserve is capped at 600; a fuel item that would exceed it is not consumed. Planks and buildings are not direct fuel. Coal is geological and nonrenewable; it cannot be crafted or grown."},
{"Charcoal", "Combine one log with river clay, then burn the charge at a lit hearth for 60 seconds with 30 fuel seconds reserved. The resulting charcoal stores 90 seconds; total fuel recovered never exceeds the log's 180 seconds."},
{"Bedroll rest", "Craft from three planks and three reeds. Right-click beneath a roof; remain still and safe for five seconds."},
{"Crop growth", "Plant grain seed on grass or soil. Growth samples historical local solar climate; cold weather pauses it. Harvest ripe crops for grain, then reserve one kernel to replant."},
{"Renewal", "Berry bushes return after one real day. A planted sapling grows after three days when its bounded tree volume is clear. No resource can regrow above map limits or through claims."},
{"Renewal", "Berry crops rest one mean real year (365.2425 days), then require their fruit season; no 24-hour refill or missed-crop replay. Human and animal botanical fruit share one readiness deadline. Pollination adds at most one berry to an existing crop once. A planted generic sapling requires one real year and clear bounded tree volume; named trees use species lifecycle endpoints, often years to decades. Unknown timing remains labelled policy or UNKNOWN, never invented biology. No resource grows through claims or map limits."},
{"Preservation", "Combine cooked food with reeds into a drying charge, then process it at a lit hearth. Dried rations are shelf-stable; general food spoilage is currently off."},
{"Spring fish", "Spring catches can produce two fish per baited trap. Search this index for fish preparation; it is seasonal but not time-critical."},
{"Pottery", "Shape six river clay into an unfired jar, then fire it at a lit hearth. Use the fired jar with planks to make a larger pantry."},
@@ -207,8 +214,8 @@ function hearth.recipes(player, query, page)
.. "box[0.65,2.2;9.3,0.8;" .. ink.mantle .. "]"
.. "field[0.7,2.25;9.2,0.7;query;Result or ingredient (plain text);" .. esc(query) .. "]field_close_on_enter[query;false]"
.. "button[10.25,2.25;3.1,0.7;search;Search]"
.. "tooltip[query;Up to 48 characters. Search matches literal text, not patterns.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. "tooltip[search;Search crafting recipes and real-time hearth processes.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("query","Up to 48 characters. Search matches literal text, not patterns.")
.. tooltip("search","Search crafting recipes and real-time hearth processes.")
.. label(0.7,3.3,"Page " .. page .. " / " .. pages_count .. " / " .. #all .. " matches",ink.muted)
.. "textarea[0.65,3.7;12.7,4.6;;;" .. esc(table.concat(lines, "\n\n")) .. "]"
.. (page > 1 and "button[0.7,8.85;2.8,0.65;previous;Previous]" or "")
@@ -284,7 +291,7 @@ function hearth.botanical_guide(player, query, page)
session.botany_query, session.botany_page = query, page
C.show_formspec(name,"hearth:botany",journal(14,10)
.. label(0.65,0.55,"eom.dev / BOTANICAL FIELD GUIDE",ink.mauve)
.. label(0.65,1.15,#records .. " named species / eight categories of ten in the full catalogue",ink.yellow)
.. label(0.65,1.15,#records .. " named species / eight ecological groups of uneven sizes",ink.yellow)
.. divider(0.65,1.65,12.7)
.. "field[0.7,1.95;9.2,0.7;query;Species, scientific name, category, habitat or role;" .. esc(query) .. "]field_close_on_enter[query;false]"
.. "button[10.25,1.95;3.1,0.7;search;Search]"
@@ -292,13 +299,201 @@ function hearth.botanical_guide(player, query, page)
.. "textarea[0.65,3.3;12.7,4.35;;;" .. esc(table.concat(lines,"\n\n")) .. "]"
.. "textarea[0.65,7.7;12.7,0.55;;;" .. esc("Game-only notes, NOT a real-world forage guide. Unflagged does not mean safe to eat. Roles are conditional, not universal effects.") .. "]"
.. (#choices > 0 and "dropdown[0.7,8.3;5,0.45;species_choice;" .. table.concat(choices,",") .. ";1;true]"
.. "tooltip[species_choice;Species article]button[6,8.3;3.3,0.45;species_article;Species article]" or "")
.. tooltip("species_choice","Species article") .. "button[6,8.3;3.3,0.45;species_article;Species article]" or "")
.. "button[10.05,8.3;3.3,0.45;fauna_index;Fauna guide]"
.. (page > 1 and "button[0.7,8.85;2.2,0.65;previous;Previous]" or "")
.. (page < count and "button[3.05,8.85;2.2,0.65;next;Next]" or "")
.. "button[6,8.85;3.3,0.65;ecosystem_survey;Ecosystem survey]"
.. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
end
-- Observational catalogue, independent of the plant-only article reader.
local function fauna_observations(player, discovered_id)
local F=hearth.fauna
if not F or type(F.list)~="function" or type(player.get_meta)~="function" then return nil end
local allowed={}; for _,record in ipairs(F.list()) do allowed[record.id]=true end
local ok,meta=pcall(function() return player:get_meta() end)
if not ok or not meta or type(meta.get_string)~="function" or type(meta.set_string)~="function" then return nil end
local key="hearth:fauna_observations_v1"
local read_ok,raw=pcall(meta.get_string,meta,key)
if not read_ok or type(raw)~="string" or #raw>512 then return nil end
local seen,ordered={},{}
if raw~="" then
for id in raw:gmatch("[^,]+") do
if not allowed[id] or seen[id] then return nil end
seen[id]=true; ordered[#ordered+1]=id
end
table.sort(ordered)
if table.concat(ordered,",")~=raw then return nil end
end
local first=discovered_id and not seen[discovered_id] or false
if first then
seen[discovered_id]=true; ordered[#ordered+1]=discovered_id; table.sort(ordered)
local encoded=table.concat(ordered,",")
local write_ok=pcall(meta.set_string,meta,key,encoded)
local verify_ok,verified=pcall(meta.get_string,meta,key)
if not write_ok or not verify_ok or verified~=encoded then return nil end
end
return seen,first
end
function hearth.inspect_fauna(player,id,snapshot)
local ok,name=pcall(function() return player:get_player_name() end)
if not ok or type(name)~="string" or #name<1 or #name>64 or not hearth.sessions[name]
or type(C.get_player_by_name)~="function" then return false end
local online_ok,online=pcall(C.get_player_by_name,name)
if not online_ok or online~=player then return false end
local F=hearth.fauna
if type(id)~="string" or #id>80 or not F or type(F.get)~="function" then return false end
local loaded,record=pcall(F.get,id)
if not loaded or type(record)~="table" or record.id~=id then return false end
local view
if snapshot~=nil then
if type(snapshot)~="table" or getmetatable(snapshot)~=nil then return false end
view={}
for _,key in ipairs({"born_at","origin","foodreserve","resident","observation"}) do
local value=snapshot[key]
if key=="born_at" or key=="foodreserve" then
if type(value)~="number" or value~=value or math.abs(value)==math.huge then return false end
elseif value~=nil and type(value)~="string" and type(value)~="boolean" then return false end
view[key]=value
end
if view.born_at<0 or view.born_at%1~=0 or view.foodreserve<0 or view.foodreserve>1
or type(view.resident)~="boolean"
or (view.origin~="established" and view.origin~="born")
or (view.observation~="rest" and view.observation~="awake") then return false end
end
local sightings,first_sighting=fauna_observations(player,view and id or nil)
local function safe(value,limit)
if type(value)~="string" then return "unknown" end
return value:gsub("[%c]"," "):sub(1,limit or 240)
end
local function safe_words(set)
local out={}
if type(set)=="table" then
for key,enabled in pairs(set) do
if enabled and type(key)=="string" and #out<32 then out[#out+1]=safe(key,40):gsub("_"," ") end
end
end
table.sort(out)
return #out>0 and table.concat(out,", ") or "not specified"
end
local profile=record.profile or {}
local lines={}
local now_ok,now=pcall(os.time)
local phase
if view and now_ok and type(F.phase)=="function" then
local phase_ok,result=pcall(F.phase,id,view.born_at,now)
if phase_ok and type(result)=="table" then phase=result end
end
if phase then
lines[#lines+1]=string.format("Lifecycle: %s / approx %.2f model years; %.1f real calendar days",safe(phase.phase,40),phase.age_days/365.2425,phase.age_days)
else
lines[#lines+1]="Lifecycle age: unavailable for this profile or no individual selected."
end
if view then
local reserve=math.max(0,math.min(1,view.foodreserve))
lines[#lines+1]=string.format("Coarse modeled food reserve (policy, not biology): %.0f%%",reserve*100)
lines[#lines+1]="Current animation observation: "..(view.observation=="rest" and "resting" or "awake / active")
lines[#lines+1]="Residency: "..(view.resident and "persistent registered resident" or "persistent mapblock resident; registry ID unavailable")
.." ("..safe(view.origin,24).." origin); individual position is not shown."
lines[#lines+1]=sightings and (first_sighting and "Field journal: first recorded encounter with this species."
or "Field journal: this species is already recorded.") or "Field journal: unavailable; encounter was not recorded."
else
lines[#lines+1]="Individual observation: none; this is a species profile, not a local census."
lines[#lines+1]="Field journal: browsing this catalogue entry does not count as a wildlife encounter."
end
lines[#lines+1]="Modeled diet: "..safe_words(record.foods)
lines[#lines+1]="Activity / sleep policy: "..safe(record.activity,32).." / "..safe(record.sleep and record.sleep.kind,40)
lines[#lines+1]="Winter rest policy: "..(record.winter_dormant and "coarse dormancy; resident remains persistent" or "no special winter dormancy modeled")
lines[#lines+1]="Modeled behavior: "..safe(profile.movement or "movement details unavailable",220)
lines[#lines+1]="Modeled prey: "..safe_words(record.prey)
if profile.reproduction_enabled==true then
lines[#lines+1]="Reproduction: enabled only within the coarse model; mature compatible pairs, season, food and safe habitat are still required."
else
local evidence=profile.evidence and profile.evidence.reproduction_context
lines[#lines+1]="Reproduction: disabled / incomplete species-specific lifecycle evidence. "
..safe(evidence and evidence.note or "No breeding policy is enabled for this profile.",220)
end
local status=hearth.wildlife
if type(status)=="table" and type(status.status)=="function" then
local status_ok,data=pcall(status.status)
if status_ok and type(data)=="table" and type(data.counts)=="table" then
local n=tonumber(data.counts[id]); if n and n==n and math.abs(n)<math.huge then
lines[#lines+1]="Active loaded animals of this species: "..math.max(0,math.floor(n))
end
end
end
local source=safe(type(record.source_url)=="string" and record.source_url or "source unavailable",300)
local lifecycle_source=safe(type(profile.source_url)=="string" and profile.source_url or "UNKNOWN",300)
C.show_formspec(name,"hearth:fauna_detail",journal(14,10)
..label(0.65,0.55,"eom.dev / FAUNA FIELD NOTES",ink.mauve)
..label(0.65,1.15,safe(record.common,80):upper().." / "..safe(record.latin or "identity unverified",100),ink.yellow)
..divider(0.65,1.65,12.7)
.."textarea[0.65,2.0;12.7,5.7;;;"..esc(table.concat(lines,"\n\n")).."]"
.."textarea[0.65,7.8;12.7,0.8;;;"..esc("Identity source: "..source.."\nLifecycle source: "..lifecycle_source).."]"
.."button[6.15,8.85;3.3,0.65;fauna_index;Back to fauna guide]"
.."button_exit[10.05,8.85;3.3,0.65;close;Close]" )
return true
end
function hearth.fauna_guide(player,query,page)
local name=player:get_player_name()
local session=hearth.sessions[name]; if not session then return end
query=tostring(query or ""):sub(1,48):gsub("[%c]"," ")
local F=hearth.fauna
local records=F and type(F.list)=="function" and F.list() or {}
local observations=fauna_observations(player)
local observed_count=0
if observations then for _ in pairs(observations) do observed_count=observed_count+1 end end
local all,all_ids,needle={},{},query:lower()
local function timing(p,field)
local r=p and p[field]
if type(r)~="table" then return "UNKNOWN" end
return tostring(r.min).."-"..tostring(r.max).." real days ["..tostring(r.confidence).."]"
end
for _,r in ipairs(records) do
local p=r.profile
local marker=observations and observations[r.id] and " / recorded encounter" or " / not yet encountered"
local row=r.id.." / "..r.common.." / "..(r.latin or "UNKNOWN species identity (legacy deer)")..marker
.."\nHabitat/movement medium: "..r.medium.."; activity: "..r.activity
.."; food roles: "..words(r.foods)
.."\nMaturity: "..timing(p,"maturity_days").."; longevity context: "..timing(p,"lifespan_days")
.."\nLifecycle evidence: "..(p and p.confidence or "UNKNOWN")
.."; automatic breeding "..(p and p.reproduction_enabled==true and "enabled only for sourced profiles" or "disabled (incomplete profile)")
.."; no lifespan-endpoint death."
.."\nIdentity source: "..r.source_url
.."\nLifecycle source: "..(p and p.source_url or "UNKNOWN")
if needle=="" or row:lower():find(needle,1,true) then all[#all+1]=row; all_ids[#all_ids+1]=r.id end
end
local pages_count=math.max(1,math.ceil(#all/5)); page=index(page,pages_count)
local lines,choices={},{}
session.fauna_ids,session.fauna_selected={},1
for i=(page-1)*5+1,math.min(#all,page*5) do
lines[#lines+1]=all[i]; session.fauna_ids[#session.fauna_ids+1]=all_ids[i]; choices[#choices+1]=esc(all_ids[i])
end
if #lines==0 then lines[1]=#records==0 and "Fauna catalogue unavailable in this runtime."
or "No animal matches that literal search." end
session.fauna_query,session.fauna_page=query,page
C.show_formspec(name,"hearth:fauna",journal(14,10)
..label(0.65,0.55,"eom.dev / FAUNA FIELD GUIDE",ink.mauve)
..label(0.65,1.15,(observations and (observed_count.." / "..#records.." species recorded in your field journal")
or "Field journal unavailable / source evidence is not complete biology"),ink.yellow)
..divider(0.65,1.65,12.7)
.."field[0.7,1.95;9.2,0.7;query;Species, scientific name, food or status;"..esc(query).."]field_close_on_enter[query;false]"
.."button[10.25,1.95;3.1,0.7;search;Search]"
..label(0.7,2.95,"Page "..page.." / "..pages_count.." / "..#all.." matches",ink.muted)
.."textarea[0.65,3.3;12.7,4.0;;;"..esc(table.concat(lines,"\n\n")).."]"
.."textarea[0.65,7.4;12.7,0.8;;;"..esc("Real calendar age includes unloaded time; no offline meals, births or event replay. Movement and feeding cadence are conservative game policies, not complete true-life behavior. No web requests or world changes.").."]"
..(#choices>0 and "dropdown[0.7,8.25;5.0,0.45;species_choice;"..table.concat(choices,",")..";1;true]"
..tooltip("species_choice","Choose one of the species shown on this page.")
.."button[6,8.2;3.3,0.55;species_details;Species details]" or "")
..(page>1 and "button[0.7,8.85;2.2,0.65;previous;Previous]" or "")
..(page<pages_count and "button[3.05,8.85;2.2,0.65;next;Next]" or "")
.."button[6,8.85;3.3,0.65;botany_index;Botanical guide]"
.."button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
end
function hearth.inventory(player)
player:set_inventory_formspec(journal(12.4,11.3)
.. label(0.6,0.45,"eom.dev / THE LAST HEARTH",ink.mauve)
@@ -307,9 +502,9 @@ function hearth.inventory(player)
.. label(4.8,2.2,"RESULT",ink.teal) .. label(6.5,1.95,"GATHER / BUILD / BELONG",ink.yellow)
.. "textarea[6.5,2.4;5.2,1.7;;;Collect flint and sticks first.\nA roof keeps rain off.\nA hearth makes shelter a home.]"
.. "button[7.1,4.35;3.8,0.6;field_guide;Open field notes]"
.. "tooltip[field_guide;Read survival notes, recipes and the ecosystem survey.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("field_guide","Read survival notes, recipes and the ecosystem survey.")
.. "button[7.1,3.65;3.8,0.6;character_creator;Character appearance]"
.. "tooltip[character_creator;Preview woodland clothing and features. Cosmetic only; Save / Apply to keep changes.]"
.. tooltip("character_creator","Preview woodland clothing and features. Cosmetic only; Save / Apply to keep changes.")
.. label(0.6,5.25,"SATCHEL / first row is your hotbar",ink.muted)
.. "list[current_player;main;0.6,5.7;10,4;]listring[current_player;main]listring[current_player;craft]")
end
@@ -322,8 +517,8 @@ function hearth.location_form(player, error_text)
.. "box[0.6,3.95;4.5,0.8;" .. ink.mantle .. "]box[5.55,3.95;4.5,0.8;" .. ink.mantle .. "]"
.. "field[0.65,4;4.4,0.7;latitude;Latitude (-90 to 90);]field_close_on_enter[latitude;false]"
.. "field[5.6,4;4.4,0.7;longitude;Longitude (-180 to 180);]field_close_on_enter[longitude;false]"
.. "tooltip[latitude;Approximate decimal degrees, at most 16 characters.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. "tooltip[longitude;Local world only. Shared servers use administrator-selected coordinates.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("latitude","Approximate decimal degrees, at most 16 characters.")
.. tooltip("longitude","Local world only. Shared servers use administrator-selected coordinates.")
.. "textarea[0.65,4.95;9.5,0.75;;;" .. esc(C.colorize(error_text and ink.danger or ink.muted,
tostring(error_text or "No location chosen: neutral equatorial preview until configured."):sub(1,180))) .. "]"
.. "button[0.65,6;4.4,0.7;save_location;Use this location]"
@@ -359,9 +554,27 @@ function hearth.ecosystem(player)
.. "\nAquatic niche: " .. (type(h.aquatic) == "string" and h.aquatic:sub(1,32) or "unavailable")
.. " Water depth: " .. (h.near_water ~= nil and optional(h.water) or "unavailable")
end
local fauna = w and string.format("Hares: %d Deer: %d Foxes: %d\nActive total: %d / %d globally Local cap: %d Per-player cap: %d",
number(w.hare),number(w.deer),number(w.fox),number(w.total),number(w.global_cap),number(w.local_cap),number(w.player_cap))
or "Wildlife observations are not available in this runtime."
local fauna = "Wildlife observations are not available in this runtime."
if w then
local counts,lines=w.counts,{}
-- Legacy optional API fallback; production uses every returned species.
counts=type(counts)=="table" and counts or {hare=w.hare,deer=w.deer,fox=w.fox}
local ids={}; for id in pairs(counts) do if type(id)=="string" then ids[#ids+1]=id end end
table.sort(ids)
for i=1,math.min(#ids,64) do
local id=ids[i]
local name=({hare="Hares",deer="Deer",fox="Foxes"})[id] or id:sub(1,64):gsub("_"," ")
lines[#lines+1]=name .. ": " .. number(counts[id])
end
fauna=table.concat(lines," / ") .. string.format("\nActive total: %d / %d globally Local cap: %d Per-player cap: %d",
number(w.total),number(w.global_cap),number(w.local_cap),number(w.player_cap))
local recruitment=w.recruitment
if type(recruitment)=="table" then
local reason=type(recruitment.last_reason)=="string" and recruitment.last_reason:sub(1,40):gsub("[%c]"," ") or "unavailable"
fauna=fauna..string.format("\nAdmissions: %d / %d attempts Latest outcome: %s",
number(recruitment.successes),number(recruitment.attempts),reason)
end
end
C.show_formspec(player:get_player_name(), "hearth:ecosystem", journal(14,10)
.. label(0.65,0.55,"eom.dev / LIVING ECOSYSTEMS",ink.mauve)
.. label(0.65,1.15,"A MOMENT IN THE MEADOW",ink.yellow) .. divider(0.65,1.65,12.7)
@@ -370,11 +583,12 @@ function hearth.ecosystem(player)
.. label(0.7,4.85,h and (h.loaded and "Loaded sample / natural-origin plant counts; ground and shade include terrain" or "Incomplete sample / unloaded cells are unknown, not empty") or "Sample unavailable",
h and h.loaded and ink.muted or ink.yellow)
.. label(0.7,5.15,"WILDLIFE / all currently active animals, not a local census",ink.teal)
.. "textarea[0.65,5.55;12.7,1.25;;;" .. esc(fauna) .. "]"
.. "textarea[0.65,7.15;12.7,1.05;;;" .. esc("Observation does not advance succession or spawn animals. No terrain is loaded.\nNatural succession pauses while unloaded; crops and planted saplings follow separate rules.") .. "]"
.. "textarea[0.65,5.55;12.7,1.55;;;" .. esc(fauna) .. "]"
.. "textarea[0.65,7.15;12.7,1.05;;;" .. esc("Observation does not advance succession or spawn animals. No terrain is loaded.\nCalendar age includes unloaded time; ecological work is bounded and loaded-only. No missed-event replay; unknown breeding is disabled.") .. "]"
.. "button[0.7,8.85;2.8,0.65;refresh;Observe again]"
.. "tooltip[refresh;Read one fresh sample without changing the world.;" .. ink.mantle .. ";" .. ink.text .. "]"
.. tooltip("refresh","Read one fresh sample without changing the world.")
.. "button[3.7,8.85;3.3,0.65;botany_index;Botanical guide]"
.. "button[7.15,8.85;2.6,0.65;fauna_index;Fauna guide]"
.. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
end
@@ -382,6 +596,27 @@ C.register_on_player_receive_fields(function(player, form, fields)
if form == "hearth:species" then
reader.handle(player,fields)
return true
elseif form == "hearth:fauna_detail" then
local session=hearth.sessions[player:get_player_name()]
if not session then return true end
if fields.fauna_index then hearth.fauna_guide(player,session.fauna_query or "",session.fauna_page or 1) end
return true
elseif form == "hearth:fauna" then
local session=hearth.sessions[player:get_player_name()]; if not session then return true end
if fields.field_notes then hearth.guide(player,6)
elseif fields.botany_index then hearth.botanical_guide(player,"",1)
elseif fields.species_details then
local value=fields.species_choice
local n=type(value)=="string" and #value<=2 and value:match("^%d+$") and tonumber(value)
if n and n>=1 and n<=#(session.fauna_ids or {}) then
session.fauna_selected=n
local id=(session.fauna_ids or {})[n]
if id then hearth.inspect_fauna(player,id,nil) end
end
elseif fields.search or fields.key_enter then hearth.fauna_guide(player,fields.query,1)
elseif fields.next then hearth.fauna_guide(player,session.fauna_query,(session.fauna_page or 1)+1)
elseif fields.previous then hearth.fauna_guide(player,session.fauna_query,(session.fauna_page or 1)-1) end
return true
elseif form == "hearth:botany" then
local session = hearth.sessions[player:get_player_name()]
if not session then return true end
@@ -391,7 +626,8 @@ C.register_on_player_receive_fields(function(player, form, fields)
if not n or n<1 or n>#(session.botany_ids or {}) then return true end
session.botany_selected = n
end
if fields.field_notes then hearth.guide(player,6)
if fields.fauna_index then hearth.fauna_guide(player,"",1)
elseif fields.field_notes then hearth.guide(player,6)
elseif fields.ecosystem_survey then hearth.ecosystem(player)
elseif fields.species_article then
local id = (session.botany_ids or {})[session.botany_selected or 1]
@@ -402,7 +638,8 @@ C.register_on_player_receive_fields(function(player, form, fields)
return true
elseif form == "hearth:ecosystem" then
if not hearth.sessions[player:get_player_name()] then return true end
if fields.field_notes then hearth.guide(player,6)
if fields.fauna_index then hearth.fauna_guide(player,"",1)
elseif fields.field_notes then hearth.guide(player,6)
elseif fields.botany_index then hearth.botanical_guide(player,"",1)
elseif fields.refresh then hearth.ecosystem(player) end
return true
@@ -420,6 +657,7 @@ C.register_on_player_receive_fields(function(player, form, fields)
if fields.recipe_index then hearth.recipes(player, "", 1); return true end
if fields.ecosystem_survey then hearth.ecosystem(player); return true end
if fields.botany_index then hearth.botanical_guide(player,"",1); return true end
if fields.fauna_index then hearth.fauna_guide(player,"",1); return true end
if fields.inspect_target then
local ok, message = inspect(player, "")
if not ok and hearth.notify then hearth.notify(player,message) end
@@ -463,6 +701,12 @@ C.register_chatcommand("botany", {params = "[search]", description = "Browse nam
hearth.botanical_guide(player,param or "",1)
return true
end})
C.register_chatcommand("fauna", {params="[search]",description="Observe animal catalogue sources and conservative lifecycle status",
func=function(name,param)
local player=C.get_player_by_name(name)
if not player or not hearth.sessions[name] then return false,"Join the world first." end
hearth.fauna_guide(player,param or "",1); return true
end})
C.register_chatcommand("inspect", {params = "[exact species ID or Latin name]",
description = "Read a targeted species' offline Wikipedia excerpt and current game effects",
func = function(name, param)
+387
View File
@@ -0,0 +1,387 @@
-- Pure calendar policy, Lua 5.1+. No engine, clock reads, registrations or writes.
-- Ranges describe populations or explicit policy fallbacks, never death deadlines.
local L = {}
local DAY, YEAR, MAX_CLOCK = 86400, 365.2425, 9007199254740991
local function finite(n) return type(n)=="number" and n==n and math.abs(n)<math.huge end
local function clock(n) return finite(n) and n>=0 and n<=MAX_CLOCK end
local function copy(t)
if type(t)~="table" then return t end
local out={}; for k,v in pairs(t) do out[k]=copy(v) end; return out
end
local function range(a,b,url,note,statistic)
return {min=a,max=b,source_url=url or false,confidence=url and "source_reported" or "policy_fallback",
note=note or "Unverified group policy; not a measured species range.",statistic=statistic or "policy_window"}
end
local function years(a,b,url,note,statistic) return range(a*YEAR,b*YEAR,url,note,statistic) end
local WT="https://www.woodlandtrust.org.uk/trees-woods-and-wildlife/"
local WILDLIFE="https://www.wildlifetrusts.org/wildlife-explorer/"
local WIKI="https://en.wikipedia.org/wiki/"
local ADW="https://animaldiversity.org/accounts/"
local RSPB="https://www.rspb.org.uk/birds-and-wildlife/"
local plants, animals = {}, {}
local baseline={
trees={range(14,365),years(1,5),years(10,60),years(60,500)},
shrubs={range(14,365),years(1,2),years(2,10),years(5,80)},
grasses={range(7,90),range(30,90),range(90,365),years(2,10)},
herbs={range(7,90),range(90,365),range(365,730),years(2,10)},
ferns_mosses={range(30,730),years(1,2),years(2,5),false},
fungi={false,false,false,false}, aquatic={range(7,90),range(90,365),range(365,730),false},
lichens={false,years(1,5),false,false},
}
local function plant_record(id,group,latin,pending)
local b=baseline[group]
plants[id]={id=id,kind="plant",group=group,latin=latin or false,pending=pending or false,
germination_days=copy(b[1]),juvenile_days=copy(b[2]),maturity_days=copy(b[3]),lifespan_days=copy(b[4]),
life_cycle=group=="fungi" and "not_applicable" or "perennial",
life_cycle_confidence="policy_fallback",source_url=false,confidence="policy_fallback",
evidence={},seasonal_policy="dormant_not_dead",age_origin="seed_or_propagule"}
end
-- Fixed snapshot of all 100 registered IDs; no catalogue factory is invoked here.
local groups={
trees="oak birch pine willow alder beech aspen rowan hazel spruce",
shrubs="bilberry blackberry raspberry elder blackthorn hawthorn dog_rose holly juniper guelder_rose",
grasses="sedge bulrush meadow_fescue cocksfoot timothy sweet_vernal_grass yorkshire_fog tufted_hairgrass common_reed soft_rush",
herbs="clover nettle heather bluebell yarrow foxglove meadowsweet wild_garlic cowslip wood_anemone",
ferns_mosses="bracken lady_fern male_fern harts_tongue polypody sphagnum haircap_moss moss fork_moss star_moss",
fungi="fly_agaric birch_bolete bay_bolete chanterelle wood_blewit shaggy_inkcap turkey_tail oyster_fungus honey_fungus birch_polypore",
aquatic="duckweed yellow_waterlily white_waterlily water_crowfoot water_mint water_forgetmenot brooklime water_starwort hornwort pondweed",
lichens="reindeer_lichen oakmoss old_mans_beard lichen dog_lichen lungwort_lichen sunburst_lichen hammered_shield cup_lichen pixie_cup",
}
for group,text in pairs(groups) do for id in text:gmatch("%S+") do plant_record(id,group) end end
-- The added twenty are registered by botany, not by this pure module.
local extra_plants={
{"dandelion","Taraxacum officinale"},{"daisy","Bellis perennis"},{"red_clover","Trifolium pratense"},
{"birdsfoot_trefoil","Lotus corniculatus"},{"ribwort_plantain","Plantago lanceolata"},
{"selfheal","Prunella vulgaris"},{"knapweed","Centaurea nigra"},{"oxeye_daisy","Leucanthemum vulgare"},
{"field_scabious","Knautia arvensis"},{"teasel","Dipsacus fullonum"},{"cow_parsley","Anthriscus sylvestris"},
{"garlic_mustard","Alliaria petiolata"},{"honeysuckle","Lonicera periclymenum","shrubs"},
{"ivy","Hedera helix","shrubs"},{"gorse","Ulex europaeus","shrubs"},{"broom","Cytisus scoparius","shrubs"},
{"crab_apple","Malus sylvestris","trees"},{"wild_cherry","Prunus avium","trees"},
{"field_maple","Acer campestre","trees"},{"holly_fern","Polystichum setiferum","ferns_mosses"},
}
for _,r in ipairs(extra_plants) do plant_record(r[1],r[3] or "herbs",r[2],false) end
local function evidence(p,field,url,note)
p.evidence[field]={source_url=url,note=note,reviewed="2026-11-03"}
p.source_url=p.source_url or url; p.confidence="partial_species_evidence"
end
local function field(p,name,value)
p[name]=value; evidence(p,name,value.source_url,value.note)
end
-- All ten trees have distinct profiles. Unsourced timing fields retain their
-- own policy_fallback label even when a different field has species evidence.
local tree_rows={
{"oak","Quercus robur",40,60,500,1000,"https://www.forestryengland.uk/article/english-oak"},
{"birch","Betula pendula",10,20,60,90,"https://treesforlife.org.uk/into-the-forest/trees-plants-animals/trees/birch/birch-facts/"},
{"pine","Pinus sylvestris",15,30,150,300,WIKI.."Pinus_sylvestris"},
{"willow","Salix alba",5,15,60,120,WIKI.."Salix_alba"},
{"alder","Alnus glutinosa",10,20,60,160,WIKI.."Alnus_glutinosa"},
{"beech","Fagus sylvatica",10,30,300,500,WIKI.."Fagus_sylvatica"},
{"aspen","Populus tremula",10,20,80,200,WIKI.."Populus_tremula"},
{"rowan","Sorbus aucuparia",10,10,80,150,WIKI.."Sorbus_aucuparia"},
{"hazel","Corylus avellana",3,10,60,80,WT.."british-trees/a-z-of-british-trees/hazel/"},
{"spruce","Picea abies",20,40,200,532,WIKI.."Picea_abies"},
}
for _,r in ipairs(tree_rows) do
local p=plants[r[1]]; p.latin=r[2]
p.maturity_days=years(r[3],r[4])
p.lifespan_days=years(r[5],r[6])
evidence(p,"natural_history",r[7],"Species reference; only separately attributed fields are verified numerical claims.")
end
field(plants.oak,"maturity_days",years(40,40,tree_rows[1][7],"Acorns usually begin around 40 years; approximate report, not a universal minimum.","approximate_onset"))
field(plants.oak,"lifespan_days",years(500,1000,WIKI.."Quercus_robur","Long-lived survivors often exceed 500 years; some exceed 1000. Not a mortality distribution.","longevity_envelope"))
field(plants.birch,"lifespan_days",years(60,90,tree_rows[2][7],"Typical birch longevity; page covers silver and downy birch and notes exceptional ages above 150.","typical_range"))
field(plants.pine,"lifespan_days",years(150,300,tree_rows[3][7],"Normal reported lifespan; exceptional trees exceed this range.","typical_range"))
field(plants.alder,"lifespan_days",years(60,160,tree_rows[5][7],"Composite envelope: Woodland Trust approximately 60; Wikipedia up to 160. Not typical 60-160 survival.","composite_envelope"))
evidence(plants.alder,"lifespan_context",WT.."british-trees/a-z-of-british-trees/alder/","Approximately 60 years; contrasts with upper longevity reports.")
field(plants.beech,"maturity_days",years(10,30,tree_rows[6][7],"Small seed quantities around 10 years, heavy crops at least 30; different reproductive milestones.","onset_to_heavy_crop"))
field(plants.beech,"lifespan_days",years(300,500,tree_rows[6][7],"Undisturbed lifespan around 300; exceptional individual above 500.","longevity_envelope"))
field(plants.aspen,"lifespan_days",years(80,200,tree_rows[7][7],"Upper endpoint 200 reported; lower endpoint 80 is unverified policy, not a sourced range.","policy_envelope"))
plants.aspen.lifespan_days.confidence="policy_fallback" -- source supports only its upper limit
field(plants.rowan,"maturity_days",years(10,10,tree_rows[8][7],"Reported maturity at about ten years.","approximate_onset"))
field(plants.rowan,"lifespan_days",years(80,150,tree_rows[8][7],"Often not older than 80 in warmer regions; occasional 150.","longevity_envelope"))
field(plants.hazel,"lifespan_days",years(60,80,tree_rows[9][7],"Uncoppiced upper limit 80 reported; lower endpoint 60 is policy. Coppice stools can persist centuries.","policy_envelope"))
plants.hazel.lifespan_days.confidence="policy_fallback"
field(plants.spruce,"lifespan_days",years(200,532,tree_rows[10][7],"532-year ring-dated individual; lower endpoint 200 is policy, not sourced. Never use clonal root ages as stem ages.","policy_envelope"))
plants.spruce.lifespan_days.confidence="policy_fallback"
for _,id in ipairs({"pine","spruce","holly","ivy","gorse","holly_fern","harts_tongue","polypody"}) do
plants[id].seasonal_policy="retain_foliage_policy"
end
for _,id in ipairs({"fungi","lichens","ferns_mosses"}) do
for _,p in pairs(plants) do if p.group==id then p.age_origin="propagule_or_colony" end end
end
local garlic=WILDLIFE.."wildflowers/garlic-mustard"
plants.garlic_mustard.life_cycle="biennial"; plants.garlic_mustard.life_cycle_confidence="source_reported"
evidence(plants.garlic_mustard,"life_cycle",garlic,"First-season leaves retained over winter; flowers in second spring.")
plants.garlic_mustard.maturity_days=range(365,730,false,"Second-season policy window; not an exact source-measured number of days.")
plants.garlic_mustard.lifespan_days=range(365,730,false,"Two-season policy; do not kill at the first frost.")
local foxglove=WILDLIFE.."wildflowers/foxglove"
plants.foxglove.life_cycle="biennial"; plants.foxglove.life_cycle_confidence="source_reported"
evidence(plants.foxglove,"life_cycle",foxglove,"Flower stems in second year; biennial representation, not a guaranteed death date.")
plants.foxglove.maturity_days=copy(plants.garlic_mustard.maturity_days)
-- Registered teasel remains explicitly unverified rather than inventing a citation.
plants.teasel.life_cycle="biennial"
local function animal_record(id,latin,path,diet,movement,life_a,life_b)
local url=WILDLIFE..path
local p={id=id,latin=latin,kind="animal",pending=false,
maturity_days=false,gestation_days=false,incubation_days=false,juvenile_days=false,lifespan_days=false,
breeding_months=false,birth_count=false,birth_count_unit="young_per_litter",reproduction="unknown",
diet=diet,movement=movement,source_url=url,confidence="partial_species_evidence",evidence={},
age_origin="birth_or_hatching",reproduction_enabled=false}
animals[id]=p
evidence(p,"diet",url,diet); evidence(p,"movement",url,movement)
if life_a then field(p,"lifespan_days",years(life_a,life_b,url,"Reported average lifespan, not maximum age or a death deadline.","reported_average")) end
return p
end
animal_record("rabbit","Oryctolagus cuniculus","mammals/rabbit","grass and other vegetation","hopping; underground warrens",3,3)
animal_record("red_squirrel","Sciurus vulgaris","mammals/red-squirrel","nuts and conifer seeds","tree climbing and branch leaping; no hibernation",6,6)
animal_record("bank_vole","Myodes glareolus","mammals/bank-vole","fruit, nuts and small insects","agile; shallow burrows; no hibernation",.5,1.5)
animal_record("wood_mouse","Apodemus sylvaticus","mammals/wood-mouse","berries and seeds","mostly nocturnal; climbing and food caching",1,1)
animal_record("hedgehog","Erinaceus europaeus","mammals/european-hedgehog","invertebrates including beetles and earthworms","nocturnal; winter hibernation",2,3)
animal_record("badger","Meles meles","mammals/european-badger","worms, fruit, plants, eggs and small animals","digging; family groups in setts",5,8)
animal_record("roe_deer","Capreolus capreolus","mammals/roe-deer","tree and shrub buds and leaves; herbs and grasses","solitary in summer; loose winter groups",7,7)
animal_record("stoat","Mustela erminea","mammals/stoat","small rodents and rabbits","bounding gait; active day and night",2,5)
animal_record("weasel","Mustela nivalis","mammals/weasel","voles, mice and small birds","low running gait; enters rodent burrows",2,2)
animal_record("otter","Lutra lutra","mammals/european-otter","fish, amphibians, waterbirds and crustaceans","swimming; sheltered holts",5,10)
local function bird(id,latin,diet,movement,life)
local p=animal_record(id,latin,"unused",diet,movement)
p.source_url=RSPB..id:gsub("_","-"); p.evidence={}
evidence(p,"diet",p.source_url,diet); evidence(p,"movement",p.source_url,movement)
p.reproduction="egg"; p.birth_count_unit="eggs_per_clutch"
if life then field(p,"lifespan_days",years(life,life,p.source_url,"Reported average, not maximum or mandatory death age.","reported_average")) end
end
bird("robin","Erithacus rubecula","worms, insects, other invertebrates, seeds and fruit","hopping; territorial; mostly UK resident")
bird("blackbird","Turdus merula","worms, insects, berries and fruit","ground hopping; some seasonal migration",3)
bird("blue_tit","Cyanistes caeruleus","insects, caterpillars, seeds and nuts","clinging and flight; winter feeding flocks",3)
bird("barn_owl","Tyto alba","mice, voles, shrews and small birds","low silent hunting flight, often dawn or dusk")
bird("mallard","Anas platyrhynchos","seeds, plants, insects and shellfish","swimming and flight; resident or migratory")
animal_record("common_frog","Rana temporaria","amphibians/common-frog","invertebrates and smaller amphibians","hopping and swimming; winter shelter",5,10)
animal_record("smooth_newt","Lissotriton vulgaris","amphibians/smooth-newt","land invertebrates; aquatic crustaceans, molluscs and tadpoles","aquatic breeding; nocturnal land activity; winter shelter",6,6)
animal_record("brown_trout","Salmo trutta","freshwater-fish/brown-trout","insect larvae, small fish and flying insects","river swimming; freshwater resident form",15,20)
animal_record("bumblebee","Bombus terrestris","invertebrates/bees-and-wasps/buff-tailed-bumblebee","floral nectar and pollen","flight; underground colony; some winter-active colonies")
local worm=animal_record("earthworm","Lumbricus terrestris","unused","dead leaf litter and mineral soil","vertical burrows; moist-surface movement")
worm.source_url=WIKI.."Lumbricus_terrestris"; worm.evidence={}
evidence(worm,"diet",worm.source_url,worm.diet); evidence(worm,"movement",worm.source_url,worm.movement)
evidence(worm,"lifespan_context",worm.source_url,"Natural lifespan unknown; six years in captivity is not a wild lifespan range.")
field(worm,"maturity_days",years(1,2,worm.source_url,"At least one year development; reproductive maturity in second year.","development_window"))
worm.reproduction="paired_hermaphrodite_cocoon"
animal_record("hare","Lepus europaeus","mammals/brown-hare","vegetation and young woody bark","bounding; surface forms, not rabbit warrens",2,4)
animal_record("fox","Vulpes vulpes","mammals/red-fox","small mammals, birds, amphibians, worms and fruit","mostly nocturnal; territorial scent marking",2,3)
-- Generic legacy deer is deliberately NOT silently reidentified as roe deer.
animals.deer={id="deer",kind="animal",latin=false,pending=false,confidence="unverified_identity",source_url=false,
evidence={},maturity_days=false,juvenile_days=false,gestation_days=false,incubation_days=false,
lifespan_days=false,breeding_months=false,birth_count=false,reproduction="unknown",reproduction_enabled=false}
local function count(id,a,b)
local p=animals[id]; field(p,"birth_count",range(a,b,p.source_url,"Source-reported litter or clutch count; not guaranteed survivors.","offspring_count"))
end
count("rabbit",3,7); count("bank_vole",3,5); count("wood_mouse",4,8); count("stoat",6,12)
count("weasel",3,6); count("roe_deer",2,3); count("hare",2,4)
count("robin",4,5); count("blackbird",3,4)
local function months(id,list,url,note)
local p=animals[id]; p.breeding_months=list; evidence(p,"breeding_months",url or p.source_url,note)
end
months("roe_deer",{7,8},nil,"Mating July-August; delayed implantation; births May-June, not immediate gestation completion.")
animals.roe_deer.reproduction="delayed_implantation"
animals.stoat.reproduction="delayed_implantation"
evidence(animals.stoat,"reproduction",animals.stoat.source_url,"Summer mating, implantation delayed until following spring; no unsupported numeric gestation.")
months("blackbird",{3,4,5,6,7,8},nil,"UK nesting usually March-August, not a universal physiological limit.")
months("blue_tit",{4,5,6},nil,"UK nesting April-June; normally one brood annually.")
local hare=animals.hare
field(hare,"maturity_days",range(236,365.2425,ADW.."Lepus_europaeus/","Sexual maturity reported from about eight months to one year; conservative runtime uses the upper endpoint.","reported_range"))
field(hare,"gestation_days",range(30,42,ADW.."Lepus_europaeus/","Reported gestation range; several litters can occur during the breeding season.","reported_range"))
field(hare,"juvenile_days",range(30,30,ADW.."Lepus_europaeus/","About one month to weaning/independence; leverets are precocial at birth.","approximate_duration"))
field(hare,"birth_count",range(1,8,ADW.."Lepus_europaeus/","Reported range is 1-8 leverets; runtime uses the conservative lower endpoint.","offspring_count"))
months("hare",{1,2,3,4,5,6,7},ADW.."Lepus_europaeus/","Midwinter (January/February) through midsummer in the cited northern-range account.")
hare.reproduction="live_birth"; hare.reproduction_enabled=true
hare.breeding_seasons={Winter=true,Spring=true,Summer=true}; hare.season_policy="sourced_midwinter_to_midsummer_coarse_seasons"
hare.postpartum_days=30; hare.care_confidence="source_reported"
local fox=animals.fox
field(fox,"maturity_days",range(304,304,ADW.."Vulpes_vulpes/","ADW reports maturity at about ten months (304-day entry); both sexes are reported similarly.","reported_average"))
field(fox,"gestation_days",range(51,53,ADW.."Vulpes_vulpes/","Typical gestation is 51-53 days; the account also notes a broader 49-56-day span.","typical_range"))
field(fox,"juvenile_days",range(56,70,ADW.."Vulpes_vulpes/","Nursing/weaning lasts about 56-70 days; leaving the den at 4-5 weeks is not independence.","weaning"))
field(fox,"birth_count",range(1,9,ADW.."Vulpes_vulpes/","ADW's summary table gives 1-9 pups, while its prose gives 1-13; use the narrower table range and lower endpoint.","source_disagreement_envelope"))
months("fox",{12,1,2},ADW.."Vulpes_vulpes/","Central-range mating is reported in January-February; winter is the conservative coarse runtime window.")
fox.reproduction="live_birth"; fox.reproduction_enabled=true
fox.breeding_seasons={Winter=true}; fox.season_policy="central_range_winter_policy"
fox.postpartum_days=300; fox.care_confidence="policy_fallback"
-- Winter dormancy is a coarse loaded-world behavior, distinct from death or
-- a measured metabolic rate. These taxa retain their saved residents.
animals.hedgehog.winter_dormant=true
animals.common_frog.winter_dormant=true
animals.smooth_newt.winter_dormant=true
local rabbit=animals.rabbit
field(rabbit,"gestation_days",range(30,37,ADW.."Oryctolagus_cuniculus/","ADW reproduction reports 30-37 days; approximately 30 typical.","reported_range"))
field(rabbit,"juvenile_days",range(22,31,ADW.."Oryctolagus_cuniculus/","Weaning age, not sexual maturity.","weaning"))
-- ADW rabbit maturity prose (8 months) conflicts with an embedded 730-day
-- value. Keep maturity unknown rather than laundering that discrepancy.
evidence(rabbit,"maturity_context",ADW.."Oryctolagus_cuniculus/","Conflicting maturity values on this page: not accepted as a verified threshold.")
months("rabbit",{1,2,3,4,5,6,7,8,9,10,11,12},ADW.."Oryctolagus_cuniculus/","Can breed year-round, mostly first half of year; habitat/health still required.")
local squirrel=animals.red_squirrel
field(squirrel,"gestation_days",range(38,39,ADW.."Sciurus_vulgaris/","Gestation 38-39 days.","reported_range"))
field(squirrel,"juvenile_days",range(56,70,ADW.."Sciurus_vulgaris/","Independence eight to ten weeks, not sexual maturity.","independence"))
field(squirrel,"maturity_days",range(296,365.2425,ADW.."Sciurus_vulgaris/","Composite envelope of female 296-day entry, male 320-day entry and approximately one-year narrative.","composite_envelope"))
-- Source gives birth/litter months, not conception months: no month override.
local vole=animals.bank_vole
field(vole,"maturity_days",range(42,56,WT.."animals/mammals/bank-vole/","Females six weeks, males eight weeks; use sex-specific values if available.","sex_envelope"))
field(vole,"juvenile_days",range(25,25,WT.."animals/mammals/bank-vole/","Weaning about 25 days.","weaning"))
local mouse=animals.wood_mouse
field(mouse,"gestation_days",range(21,26,ADW.."Apodemus_sylvaticus/","Gestation range 21-26 days.","reported_range"))
field(mouse,"juvenile_days",range(21,21,ADW.."Apodemus_sylvaticus/","Weaning at three weeks.","weaning"))
field(mouse,"maturity_days",range(65,71,ADW.."Apodemus_sylvaticus/","Male 65-day and female 71-day entries; narrative approximately two months.","sex_envelope"))
field(animals.common_frog,"larval_days",range(98,98,animals.common_frog.source_url,"About 14 weeks AFTER hatching to froglet; not maturity or egg incubation.","metamorphosis"))
animals.common_frog.reproduction="external_eggs"; animals.smooth_newt.reproduction="eggs"
animals.brown_trout.reproduction="external_eggs"; animals.bumblebee.reproduction="colony_caste_dependent"
-- Additional retrieved reproductive accounts, reviewed 2026-11-03. Registration
-- status is independent of evidence. A lifespan is NOT a breeding prerequisite.
field(vole,"gestation_days",range(17,24,ADW.."Myodes_glareolus/","17-24 days, mean 21; upper endpoint includes lactation delay.","reported_range"))
months("bank_vole",{4,5,6,7,8,9},ADW.."Myodes_glareolus/","Late April-September; runtime uses conservative Spring/Summer season policy.")
months("wood_mouse",{2,3,4,5,6,7,8,9,10},WIKI.."Wood_mouse","February-October in this account; ADW instead says March-early winter.")
local blackbird=animals.blackbird
field(blackbird,"maturity_days",years(1,1,ADW.."Turdus_merula/","Usually starts mating at one year.","approximate_onset"))
field(blackbird,"incubation_days",range(13,14,ADW.."Turdus_merula/","13-day prose and 14-day summary; conservative composite.","composite_envelope"))
field(blackbird,"juvenile_days",range(13,14,ADW.."Turdus_merula/","Leaving nest at 13-14 days; NOT full independence.","fledging"))
local mallard=animals.mallard
field(mallard,"maturity_days",range(365,365,ADW.."Anas_platyrhynchos/","Both sexes reported at 365 days; yearling breeding.","reported_average"))
field(mallard,"incubation_days",range(26,28,ADW.."Anas_platyrhynchos/","Time to hatching 26-28 days.","reported_range"))
field(mallard,"birth_count",range(9,13,ADW.."Anas_platyrhynchos/","Typical hen lays 9-13 eggs; not guaranteed survivors.","clutch_count"))
field(mallard,"juvenile_days",range(50,60,WIKI.."Mallard","50-60 days to flight; not reproductive maturity.","fledging"))
local blue=animals.blue_tit
local diary="https://www.bto.org/understanding-birds/articles/blue-tit-diary"
field(blue,"incubation_days",range(14,14,diary,"About two weeks; approximate, not an exact physiological constant.","approximate_duration"))
field(blue,"birth_count",range(8,10,diary,"Majority of clutches contain 8-10 eggs; larger clutches exist.","typical_clutch"))
field(blue,"juvenile_days",range(18,21,diary,"Fledging 18-21 days; parents feed for further weeks.","fledging"))
evidence(blue,"maturity_context",diary,"Usually breeds in spring after hatching; no exact day threshold accepted. Caterpillar provisioning absent, so disabled.")
field(animals.common_frog,"maturity_days",years(3,3,WIKI.."Common_frog","Sexual maturity after three years in this account; local variation exists.","approximate_onset"))
field(animals.common_frog,"incubation_days",range(14,21,WIKI.."Common_frog","Eggs hatch after 2-3 weeks; temperature-dependent, NOT froglet age.","reported_range"))
evidence(animals.brown_trout,"reproduction_context",WIKI.."Brown_trout","About 2000 eggs/kg; requires female mass, gravel redd and temperature-dependent development, not a fixed litter.")
evidence(animals.bumblebee,"reproduction_context",WIKI.."Bombus_terrestris","Queen-founded colonies, diploid females/haploid males, larvae and pupae; caste-specific care cannot use a mammalian litter timer.")
-- Explicit seasonal/care policies, NOT source-reported month precision or a
-- claim to model nests/physiology. Upper dependency endpoints avoid postpartum
-- immediate breeding; bird care lock is deliberately longer than fledging.
for _,id in ipairs({"bank_vole","wood_mouse","blackbird","mallard"}) do
local p=animals[id]
p.reproduction_enabled=true
p.reproduction=(id=="bank_vole" or id=="wood_mouse") and "live_birth" or "egg"
p.breeding_seasons={Spring=true,Summer=true}
p.season_policy="conservative_temperate_spring_summer"
p.postpartum_days=math.max(21,p.juvenile_days.max)+(p.reproduction=="egg" and 21 or 0)
p.care_confidence="policy_fallback"
end
local function id_ok(id) return type(id)=="string" and #id<=64 and id:match("^[a-z][a-z_]*$")~=nil end
function L.plant(id,group)
if not id_ok(id) then return nil,"id" end
if plants[id] then
if group~=nil and group~=plants[id].group then return nil,"group" end
return copy(plants[id])
end
return nil,"unknown_species" -- never fabricate an identity from a group hint
end
function L.animal(id)
if not id_ok(id) then return nil,"id" end
if not animals[id] then return nil,"unknown_species" end
return copy(animals[id])
end
function L.ids(kind)
local out={}; local src=kind=="plant" and plants or kind=="animal" and animals
if not src then return nil,"kind" end
for id in pairs(src) do out[#out+1]=id end; table.sort(out); return out
end
local function valid_range(r)
return type(r)=="table" and finite(r.min) and finite(r.max) and r.min>=0 and r.max>=r.min and r.max<=1000000
end
-- Stable numeric seed, field salt <=64 bytes, bounded floating-point arithmetic.
-- Reproducible selection only; NOT cryptography and never math.randomseed().
function L.duration(r,seed,salt)
if not valid_range(r) then return nil,"range" end
if not finite(seed) or seed<0 or seed>2147483646 or seed%1~=0 then return nil,"seed" end
if type(salt)~="string" or #salt>64 then return nil,"salt" end
local h=seed+1.0
for i=1,#salt do h=(h*131.0+salt:byte(i))%2147483647.0 end
h=(h*48271.0)%2147483647.0
return r.min+(r.max-r.min)*(h/2147483647.0)
end
local durations={"germination_days","juvenile_days","maturity_days","lifespan_days","gestation_days","incubation_days","larval_days"}
function L.resolve(profile,seed)
if type(profile)~="table" or not id_ok(profile.id) then return nil,"profile" end
if not finite(seed) or seed<0 or seed>2147483646 or seed%1~=0 then return nil,"seed" end
local out=copy(profile); out.selected_days={}
for _,name in ipairs(durations) do
if profile[name] then
local n,err=L.duration(profile[name],seed,profile.id..":"..name)
if not n then return nil,err end
out.selected_days[name]=n
end
end
-- Overlapping stage ranges must not produce maturity before dependency or
-- germination. Clamp to the preceding selected endpoint, never outside a range.
local previous=0
for _,name in ipairs({"germination_days","juvenile_days","maturity_days","lifespan_days"}) do
local n=out.selected_days[name]
if n then
n=math.max(previous,n)
if n>profile[name].max then return nil,"profile_order" end
out.selected_days[name]=n; previous=n
end
end
out.selection_seed=seed; return out
end
function L.elapsed(previous,now,work_cap_seconds)
if not clock(previous) or not clock(now) then return nil,"clock" end
if now<previous then return nil,"clock_reversed" end
if not clock(work_cap_seconds) then return nil,"cap" end
local age_delta=now-previous; local work=math.min(age_delta,work_cap_seconds)
return {elapsed_seconds=age_delta,work_seconds=work,deferred_seconds=age_delta-work}
end
local function threshold(profile,name)
local r=profile[name]
if r==false or r==nil then return false end
if not valid_range(r) then return nil end
if profile.selected_days~=nil and type(profile.selected_days)~="table" then return nil end
local selected=profile.selected_days and profile.selected_days[name]
if selected~=nil then
if not finite(selected) or selected<r.min or selected>r.max then return nil end
return selected
end
return r.max -- conservative unsampled policy, not a random draw on each read
end
function L.phase(profile,born_at,now)
if type(profile)~="table" or (profile.kind~="plant" and profile.kind~="animal") then return nil,"profile" end
if not clock(born_at) or not clock(now) then return nil,"clock" end
if now<born_at then return nil,"clock_reversed" end
local age=(now-born_at)/DAY
local g,j,m,life=threshold(profile,"germination_days"),threshold(profile,"juvenile_days"),
threshold(profile,"maturity_days"),threshold(profile,"lifespan_days")
if g==nil or j==nil or m==nil or life==nil then return nil,"profile" end
if (g and j and g>j) or (j and m and j>m) or (m and life and m>life) then return nil,"profile_order" end
local phase="unknown"
if profile.kind=="plant" and g and age<g then phase="seed"
elseif j and age<j then phase=profile.kind=="plant" and "seedling" or "dependent"
elseif m and age<m then phase="juvenile"
elseif m then phase="mature" end
local beyond=life and age>=life or false
return {phase=phase,age_days=age,age_seconds=now-born_at,beyond_lifespan_range=beyond,
-- A population average or record age must NEVER be an automatic death.
death_due=false,reproductive_ready=false,confidence=profile.confidence}
end
function L.seasonal(profile,season)
if type(profile)~="table" or profile.kind~="plant" then return nil,"profile" end
if not ({Spring=true,Summer=true,Autumn=true,Winter=true})[season] then return nil,"season" end
return {dormant=season=="Winter" and profile.seasonal_policy=="dormant_not_dead",death_due=false,
confidence="policy_fallback"} -- presentation only; not a frost or survival model
end
function L.breeding_window(profile,month)
if type(profile)~="table" or profile.kind~="animal" then return nil,"profile" end
if not finite(month) or month%1~=0 or month<1 or month>12 then return nil,"month" end
local list=profile.breeding_months
if list==false or list==nil then return {known=false,within_window=false,eligible=false} end
if type(list)~="table" or #list>12 then return nil,"profile" end
local allowed=false
for _,m in ipairs(list) do
if not finite(m) or m%1~=0 or m<1 or m>12 then return nil,"profile" end
if m==month then allowed=true end
end
return {known=true,within_window=allowed,eligible=false} -- window alone never authorizes reproduction
end
L.VERSION=1; L.SECONDS_PER_DAY=DAY; L.DAYS_PER_YEAR=YEAR; L.MAX_CLOCK=MAX_CLOCK
return L
+2 -1
View File
@@ -1,5 +1,6 @@
name = hearth
title = The Last Hearth
description = Original survival-first eom.dev prototype. No external game dependencies.
description = Survival-first woodland game with original art and a pinned Creatura wildlife AI API.
author = eom.dev
min_minetest_version = 5.18
depends = creatura
+11 -6
View File
@@ -32,7 +32,7 @@ return function(hearth, api)
local function record(value)
return B.get(type(value)=="table" and value.id or value)
end
-- Identity references cover all 80 records. An identity link is not evidence
-- Identity references cover all 100 records. An identity link is not evidence
-- that every tagged trait has been reviewed or implemented.
function E.source(value)
local r=record(value); if not r then return nil end
@@ -53,10 +53,10 @@ return function(hearth, api)
local out={}; for _,r in ipairs(B.list()) do out[r.id]=E.source(r) end; return out
end
local tags={"nitrogenfixer","browse","pollinator","ectomycorrhiza","saprotroph","parasite","engineer","nursery","allelopathy","resin"}
local nitrogen="After 900 warm active seconds: at most 1 nutrient per eligible evaluation, 2 total per support via succ_fixed_nitrogen shared across species/restarts; stops at fertility 4 (soil cap 8)."
local nitrogen="After 90 real calendar days of root age, in warm active conditions: at most 1 nutrient per eligible evaluation, 2 total per support via succ_fixed_nitrogen shared across species/restarts; stops at fertility 4 (soil cap 8). Timing is game policy, not a sourced fixation rate."
local descriptions={
browse="Active natural plants count as wildlife forage; grazing consumes eligible plants, not mature trees. Not human food permission.",
pollinator="Only natural plants in their catalogue flowering season add to pollination. This input does not itself change berry timers.",
browse="Active natural plants count as wildlife forage; grazing debits a shared partial-canopy ration with a seven-real-day rest, not the perennial root. Not human food permission.",
pollinator="Mature natural plants in their flowering season support pollination. Nectar visits reserve a shared one-hour nectar deadline and at most one pollination credit expiring after one real day. Human harvesting spends that credit for at most one extra berry from an existing fruit crop; it never resets annual fruit readiness or creates seeds.",
nursery="Each active natural non-component plant adds nursery cover; any cover reduces succession stress accumulation to 75%. No extra animal spawn is promised.",
parasite="Not simulated: no infection or attack on living trees or player plants.",
allelopathy="Not simulated: no chemical suppression or plant killing.",
@@ -75,10 +75,10 @@ return function(hearth, api)
elseif tag=="browse" or tag=="pollinator" or tag=="nursery" then status="simulated"
elseif tag=="ectomycorrhiza" then
if r.group=="fungi" then
status,game="simulated","Active compatible nearby fungus permits seedling growth at 16200 instead of 21600 active seconds; successful growth debits at most 1 existing soil nutrient once, regardless of fungus count. No nutrient creation or carbon/water ledger."
status,game="simulated","Active compatible nearby fungus permits a finite nutrient exchange: successful calendar-eligible seedling growth debits at most 1 existing soil nutrient once, regardless of fungus count. No accelerated growth, nutrient creation or carbon/water ledger; lifecycle age thresholds are unchanged."
else game="Not simulated as a tree aura: only an active compatible nearby fungus can enable the finite seedling nutrient debit." end
elseif tag=="saprotroph" then
status,game="simulated","On suitable natural deadwood/litter only: advance its finite decay age by at most 60 seconds per evaluation, capped at 7200/1800. Only substrate decay pays soil nutrients once; the fungus pays none. Soil-growing saprotrophs receive no special nutrient effect."
status,game="simulated","On suitable natural deadwood/litter only: add at most 60 seconds per evaluation to its finite decay bonus, capped at one mean real year for deadwood or 90 real days for litter. Only substrate decay pays its remaining soil nutrients once; the fungus pays none. Soil-growing saprotrophs receive no special nutrient effect."
elseif tag=="engineer" then
if r.id=="sphagnum" then
status,game="simulated","Local land habitat estimate: +0.10 moisture and -0.20 pH per mat, at most 2 mats; moisture clamped 0..1, pH 3.5..8.5. No terrain, water or persistent pH writes."
@@ -91,6 +91,11 @@ return function(hearth, api)
if r.group=="trees" then
add("canopy","simulated","Trunks/crowns reduce local habitat light and contribute moisture through existing canopy sampling; pine/spruce retain full Winter influence, deciduous foliage retains 15%. Not a hydrological solver.")
end
local profile=hearth.lifecycle and hearth.lifecycle.plant(r.id)
add("lifecycle","simulated", "Calendar ages use real elapsed days, including unloaded time; reconciliation is bounded and loaded-only, with no missed-event replay. "
.. (profile and "Profile: "..profile.confidence..". Each timing field is separately source_reported, policy_fallback or UNKNOWN; an identity URL is not timing evidence."
or "Lifecycle profile UNKNOWN in this runtime; no invented maturity or lifespan.")
.. " Lifespan endpoints never authorize automatic death; fungal/lichen unknown maturity is not propagation permission.")
if r.toxic then add("toxicity","descriptive","Not simulated as an attack: this warning never grants a poison aura or human food permission.") end
return out
end
+336 -43
View File
@@ -1,4 +1,4 @@
-- Original loaded-only succession. No ABMs, map emergence, timers or world registry.
-- Calendar age with bounded loaded reconciliation. No emergence or event replay.
return function(hearth, api)
local S = {BUDGET = 8, RADIUS = 12, INTERVAL = 1, MAX_GAP = 1200, CACHE_LIMIT = 8192,
MAX_TREE_CELLS=810, MAX_WATER_DEPTH=6}
@@ -9,6 +9,16 @@ return function(hearth, api)
local trees = {tree_seedling=true, young_tree=true, mature_tree=true}
local diversity = hearth.diversity
local botany = hearth.botany
local lifecycle = hearth.lifecycle
local DAY, YEAR = 86400, 365.2425*86400
local DECAY = {dung=600,litter=90*DAY,deadwood=YEAR,fungi=2400}
-- One synchronous, separately paid cache handoff, never a saved animal seed.
local cache_nut
local function resource_now()
local t=api.now and api.now() or os.time()
if type(t)~="number" or t~=t or t<0 or t>9007199254740991-YEAR then return nil end
return math.floor(t)
end
local effects = hearth.species_effects
-- Content may inject the factory before succession. Standalone integrations
-- without it keep their old API; the engine loads only this fixed sibling.
@@ -56,6 +66,98 @@ return function(hearth, api)
if not n or n ~= n or n < 0 or n == math.huge then return 0 end
return math.min(maximum or 1000000000000, n)
end
local function valid_clock(n)
return type(n)=="number" and n==n and n>=0 and n<=9007199254740991
end
local function stored(m,f,default,maximum)
local s=m:get_string(f)
if s=="" then return default end
local n=tonumber(s)
if not valid_clock(n) or n>(maximum or 9007199254740991) then return nil end
return n
end
local function profile_for(k,p)
local id=botany and botany.kind(k) or (diversity and diversity.tree_info(k))
local profile=id and lifecycle and lifecycle.plant(id)
if not profile and (trees[k] or (k=="log" and api.get_meta(p):get_string("succ_worldgen_stage")=="mature")) and not id then
-- Unidentified legacy tree: explicit conservative family policy, not
-- a silently assigned birch identity or an accelerated timber crop.
profile={id="generic_tree",kind="plant",confidence="policy_fallback",
juvenile_days={min=365.2425,max=365.2425},maturity_days={min=3652.425,max=3652.425},
lifespan_days=false,germination_days=false}
end
if not profile then return nil end
local seed=math.abs(p.x*73856093.0+p.y*19349663.0+p.z*83492791.0)%2147483647
return profile,seed
end
-- Persist one root anchor, never reinterpret stage-local legacy active seconds.
-- Inferred establishment age is separate from the observed adoption epoch.
local function calendar(p,k,now,origin)
if not valid_clock(now) then return nil end
local profile,seed=profile_for(k,p)
if not profile then return nil end
local m=api.get_meta(p)
local version=m:get_string("succ_lifecycle_version")
if version=="" then
-- A partly erased record is corruption, not permission to reroll age.
if m:get_string("succ_birth_at")~="" or m:get_string("succ_lifecycle_profile")~="" then return nil end
local offset=0
origin=origin or m:get_string("succ_pending_origin")
if origin=="" or not origin then origin="legacy_established" end
local stage=diversity and select(2,diversity.tree_info(k))
stage=stage or ({tree_seedling="seedling",young_tree="young",mature_tree="mature"})[k]
stage=stage or (m:get_string("succ_worldgen_stage")=="mature" and "mature")
if origin~="natural_seed" then
local threshold
if stage=="seedling" then threshold=profile.germination_days
elseif stage=="young" then threshold=profile.juvenile_days
else threshold=profile.maturity_days end
offset=threshold and threshold.max*DAY or 0
end
m:set_string("succ_birth_at",tostring(now))
m:set_string("succ_initial_age_seconds",tostring(offset))
m:set_string("succ_lifecycle_seed",tostring(seed))
m:set_string("succ_lifecycle_origin",origin)
m:set_string("succ_lifecycle_profile",profile.id)
m:set_string("succ_lifecycle_policy","calendar_v1_conservative_max")
m:set_string("succ_last_observed_at",tostring(now))
m:set_string("succ_seed_epoch",tostring(now))
m:set_string("succ_lifecycle_version","1")
m:set_string("succ_pending_origin","")
end
if m:get_string("succ_lifecycle_version")~="1" or m:get_string("succ_lifecycle_profile")~=profile.id then return nil end
local born=tonumber(m:get_string("succ_birth_at"))
local initial=tonumber(m:get_string("succ_initial_age_seconds"))
local previous=tonumber(m:get_string("succ_last_observed_at"))
local stored_seed=tonumber(m:get_string("succ_lifecycle_seed"))
local epoch=tonumber(m:get_string("succ_seed_epoch"))
if not valid_clock(born) or not valid_clock(initial) or not valid_clock(previous) or not valid_clock(epoch)
or not stored_seed or stored_seed<0 or stored_seed>2147483646 or stored_seed%1~=0
or previous<born or now<previous or now<born then return nil end
local age=initial+(now-born)
if not valid_clock(age) then return nil end
local phase
if lifecycle then phase=lifecycle.phase(profile,0,age)
else
phase={phase=age<profile.juvenile_days.max*DAY and "seedling"
or (age<profile.maturity_days.max*DAY and "juvenile" or "mature"),age_seconds=age,death_due=false}
end
if not phase then return nil end
if previous~=now then m:set_string("succ_last_observed_at",tostring(now)) end
return phase,profile
end
local function flowering_ready(q,r,now)
local m=api.get_meta(q)
if m:get_string("succ_lifecycle_version")=="" then return true end -- established, not yet adopted
if m:get_string("succ_lifecycle_version")~="1" or not lifecycle then return false end
local born=tonumber(m:get_string("succ_birth_at"))
local initial=tonumber(m:get_string("succ_initial_age_seconds"))
local high=tonumber(m:get_string("succ_last_observed_at"))
if not valid_clock(born) or not valid_clock(initial) or not valid_clock(high) or now<born or now<high then return false end
local profile=lifecycle.plant(r.id)
local phase=profile and lifecycle.phase(profile,0,initial+now-born)
return phase~=nil and phase.phase=="mature"
end
local markers = {"owner", "planter", "placed_by", "player_placed", "hearth_placed", "hearth_player_placed", "planted_at"}
local function marked(p)
local meta = api.get_meta(p)
@@ -165,6 +267,7 @@ return function(hearth, api)
h.nutrients=number(api.get_meta(at(p,0,-1,0)),"succ_nutrients",8)
-- Leafless deciduous branches cast less shade than a summer canopy.
local season=hearth.seasons and hearth.seasons.season() or "Summer"
local habitat_now=os.time()
local foliage = season == "Winter" and 0.15 or 1
-- Fixed 5x5x5 stencil: includes river water, vegetation and overhead canopy.
for x=-2,2 do for z=-2,2 do for y=-1,3 do
@@ -203,10 +306,11 @@ return function(hearth, api)
end
if not h.host and r.group=="trees" then h.host=r.id end
if r.group=="grasses" then h.grass=h.grass+1 end
if botany.pollinator(k,season) and r.group~="trees" then h.flowers=h.flowers+1 end
local blooms=botany.pollinator(k,season) and flowering_ready(q,r,habitat_now)
if blooms and r.group~="trees" then h.flowers=h.flowers+1 end
if r.group=="shrubs" or r.group=="ferns_mosses" or r.group=="aquatic" then h.cover=h.cover+1 end
if botany.browse(k,season) and not component then h.edible=h.edible+1 end
if botany.pollinator(k,season) and not component then h.pollination=h.pollination+1 end
if blooms and not component then h.pollination=h.pollination+1 end
if botany.active(k,season) and r.tags.nursery and not component then h.nursery=h.nursery+1 end
if r.group=="fungi" and r.tags.ectomycorrhiza and botany.active(k,season) then h.root_fungi[#h.root_fungi+1]=r.id end
end
@@ -425,7 +529,25 @@ return function(hearth, api)
if species or origin then m:set_string("succ_species",species or origin)
elseif target~="deadwood" and target~="litter" then m:set_string("succ_species","") end
m:set_string("succ_natural", target and "1" or "")
m:set_string("succ_age", "0"); m:set_string("succ_stress", "0")
local oldprofile=m:get_string("succ_lifecycle_profile")
local nextprofile=target and profile_for(target,p)
local same=nextprofile and oldprofile==nextprofile.id
if not same then
for _,field in ipairs({"succ_birth_at","succ_initial_age_seconds","succ_last_observed_at","succ_lifecycle_version",
"succ_lifecycle_seed","succ_lifecycle_origin","succ_lifecycle_profile","succ_lifecycle_policy","succ_seed_epoch","succ_litter_epoch"}) do
m:set_string(field,"")
end
m:set_string("succ_age","0")
if nextprofile then calendar(p,target,os.time(),"natural_seed") end
end
m:set_string("succ_stress", "0")
if target=="litter" or target=="deadwood" then
m:set_string("succ_decay_at",tostring(os.time()))
m:set_string("succ_decay_seen_at",tostring(os.time()))
m:set_string("succ_decay_initial", "0")
m:set_string("succ_decay_bonus", "0")
m:set_string("succ_litter_remaining",target=="litter" and "1" or "")
end
if hearth.invalidate_environment then hearth.invalidate_environment(p) end
return true
end
@@ -480,21 +602,44 @@ return function(hearth, api)
if component~="" and component~=key(p) then return false end
local dt = active(p,now)
local m, n = api.get_meta(p), node(p)
if dt == 0 then return false end
local h = S.habitat(p)
if not h.loaded then seen[key(p)]=nil; return false end
local k = kind(n)
local f=family(k)
local species=diversity and diversity.tree_info(k)
if m:get_string("succ_worldgen_stage")=="mature" then
f="mature_tree"
local id=m:get_string("succ_species")
if id~="generic" then species=id end
end
local record=botany and botany.get(k)
local calendar_profile=profile_for(k,p)
local phase=calendar_profile and calendar(p,k,now)
if calendar_profile and not phase then return false end
-- Dead organic matter also has a calendar, independent of visit count.
-- Legacy active seconds are retained as a small observed initial age.
local organic=k=="litter" or k=="deadwood"
local decay_age
if organic then
local born=stored(m,"succ_decay_at",now)
local high=stored(m,"succ_decay_seen_at",now)
local initial=stored(m,"succ_decay_initial",number(m,"succ_age",DECAY[k]),DECAY[k])
local bonus=stored(m,"succ_decay_bonus",0,DECAY[k])
if not born or not high or not initial or not bonus or born>now or high>now then return false end
decay_age=initial+now-born+bonus
m:set_string("succ_decay_at",tostring(born)); m:set_string("succ_decay_seen_at",tostring(now))
m:set_string("succ_decay_initial",tostring(initial))
end
if dt==0 then return false end -- one cheap baseline, no side effects replay
local season=hearth.seasons and hearth.seasons.season(now) or "Summer"
local climate = hearth.climate_at and hearth.climate_at(now)
local warm = not climate or (climate.temperature or 12) >= 3
local decomposer = k=="dung" or k=="litter" or k=="deadwood" or k=="fungi"
local dormant = (record and not botany.active(k,season)) or (hearth.seasons and not hearth.seasons.available(k,now))
local age_dt = (not decomposer and (not warm or dormant)) and 0 or dt
local age = number(m,"succ_age",1000000)+age_dt
m:set_string("succ_age", tostring(age))
local age = decay_age or (phase and phase.age_seconds) or number(m,"succ_age",1000000)+age_dt
-- Keep the old stage-local counter untouched on migrated living roots.
if not phase then m:set_string("succ_age", tostring(age)) end
if number(m,"succ_next") > now then return false end
-- Commit the attempt BEFORE evaluating it, including failed competition/dispersal.
m:set_string("succ_next", tostring(now+PERIOD))
@@ -546,24 +691,29 @@ return function(hearth, api)
local stress_dt=botany and h.nursery>0 and dt*0.75 or dt
local stress = harsh and number(m,"succ_stress",100000)+stress_dt or 0
m:set_string("succ_stress",tostring(stress))
if stress>=600 then
if stress>=600 and not phase then
local remains=trees[k] and "deadwood" or "litter"
if botany and ((record and record.group=="fungi") or h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter") then remains=nil end
if diversity and diversity.is_rock(node(at(p,0,-1,0))) then remains=nil end
return change(p,remains)
end
if decomposer then
local lifetime = ({dung=600,litter=1800,deadwood=7200,fungi=2400})[k]
local lifetime = DECAY[k]
if age>=lifetime then
local remaining=k=="litter" and stored(m,"succ_litter_remaining",1,1) or 1
if not remaining then return false end
local target = k=="deadwood" and "litter" or nil
if change(p,target) then nutrients(p,k=="dung" and 4 or (k=="deadwood" and 3 or 1)); return true end
if change(p,target) then
if k=="litter" then m:set_string("succ_litter_remaining","0") end
nutrients(p,k=="dung" and 4 or (k=="deadwood" and 3 or remaining)); return true
end
elseif k~="fungi" and h.moisture>=0.55 and h.light<=12 and age>=300 then
disperse(p,"fungi",serial)
end
return false
end
if not warm then return false end
if record and age>=900 and record.tags.nitrogenfixer then
if record and age>=90*DAY and record.tags.nitrogenfixer then
local below=at(p,0,-1,0)
local soil=api.get_meta(below)
local fixed=number(soil,"succ_fixed_nitrogen",2)
@@ -572,11 +722,10 @@ return function(hearth, api)
soil:set_string("succ_fixed_nitrogen",tostring(fixed+delta))
end
end
local seed_age=21600
local seed_age=calendar_profile and calendar_profile.juvenile_days and calendar_profile.juvenile_days.max*DAY or YEAR
local exchange=0
if botany and species and f=="tree_seedling" then
for _,fungus in ipairs(h.root_fungi) do exchange=math.max(exchange,botany.root_fungi(fungus,species,fertility)) end
if exchange>0 then seed_age=16200 end
end
if f=="tree_seedling" and age>=seed_age and h.canopy<1.5 then
if change(p,species and species.."_young" or "young_tree") then
@@ -585,13 +734,19 @@ return function(hearth, api)
end
return false
end
if f=="young_tree" and age>=86400 and h.canopy<2 then return change(p,species and species.."_mature" or "mature_tree") end
local maturity=calendar_profile and calendar_profile.maturity_days and calendar_profile.maturity_days.max*DAY or 10*YEAR
if f=="young_tree" and age>=maturity and h.canopy<2 then return change(p,species and species.."_mature" or "mature_tree") end
if f=="mature_tree" then
if age>=259200 then return change(p,"deadwood") end
if age>=1800 and serial%10==0 then disperse(p,species and species.."_seedling" or "tree_seedling",serial) end
if serial%5==0 then disperse(p,"litter",serial) end
-- One present seed opportunity, never one offspring per missed year.
if phase and phase.phase=="mature" and (not record or botany.seeding(k,season))
and now>=number(m,"succ_seed_epoch",9007199254740991) then
m:set_string("succ_seed_epoch",tostring(now+YEAR))
disperse(p,species and species.."_seedling" or "tree_seedling",serial)
end
if now>=number(m,"succ_litter_epoch",9007199254740991) then
m:set_string("succ_litter_epoch",tostring(now+30*DAY)); disperse(p,"litter",serial)
end
elseif record and not trees[k] then
local lifetime=({shrubs=86400,grasses=21600,herbs=14400,ferns_mosses=43200,fungi=2400,aquatic=21600,lichens=86400})[record.group]
if record.tags.saprotroph and (h.substrate=="deadwood" or h.substrate=="litter")
and botany.suitable(k,h,season) then
-- Advance only an existing natural substrate's finite clock;
@@ -599,24 +754,17 @@ return function(hearth, api)
local below=at(p,0,-1,0)
if natural(below,node(below)) and permitted(below) then
local wood=api.get_meta(below)
local limit=h.substrate=="deadwood" and 7200 or 1800
wood:set_string("succ_age",tostring(math.min(limit,number(wood,"succ_age",limit)+math.min(dt,PERIOD))))
local limit=DECAY[h.substrate]
local bonus=stored(wood,"succ_decay_bonus",0,limit)
if bonus then wood:set_string("succ_decay_bonus",tostring(math.min(limit,bonus+math.min(dt,PERIOD)))) end
end
end
if age>=lifetime then
local remains="litter"
if record.group=="fungi" or h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter"
or (diversity and diversity.is_rock(node(at(p,0,-1,0)))) then remains=nil end
return change(p,remains)
-- Unknown maturity (fungi/lichens) is not automatic propagation or
-- death permission. A sprite persists as its root/colony observation.
if phase and phase.phase=="mature" and botany.seeding(k,season)
and now>=number(m,"succ_seed_epoch",9007199254740991) then
m:set_string("succ_seed_epoch",tostring(now+30*DAY)); disperse(p,k,serial)
end
if age>=7200 and h.water==0 and ground(at(p,0,-1,0)) and h.canopy<1.5 and (p.x*7+p.z*11)%23==0 then
local th={}; for field,value in pairs(h) do th[field]=value end; th.include_trees=true
-- The same catalogue selector is the source of all ten trees;
-- skip non-tree results without changing its deterministic order.
local recruit=botany.choose(p,th,season)
if recruit and trees[recruit] then return change(p,recruit) end
end
if age>=900 and serial%3==0 then disperse(p,k,serial) end
elseif diversity and diversity.is_worldgen_flora(k) and not trees[k] then
if age>=7200 and h.canopy<1.5 and (p.x*7+p.z*11)%23==0 then
local recruit=diversity.choose_tree(p,h)
@@ -660,25 +808,170 @@ return function(hearth, api)
end end
return false
end
function S.forage(raw)
-- All animal resource probes are read-only until their complete preflight.
-- No bootstrap/adoption, calendar writes, callbacks or inventory grants here.
local function resource_site(q)
local n=node(q)
local below=at(q,0,-1,0)
if not n or not node(below) or not natural(q,n) or not permitted(q) or not permitted(below) then return nil end
local component=api.get_meta(q):get_string("succ_component")
if component~="" and component~=key(q) then return nil end
if not ground(below) or (diversity and not diversity.is_ground(node(below))) then return nil end
return n,api.get_meta(q),below
end
local function mature_resource(q,r,time)
local m=api.get_meta(q)
if not lifecycle or not r then return false end
local profile=lifecycle.plant(r.id)
if not profile or not profile.maturity_days then return false end
if m:get_string("succ_lifecycle_version")=="" then
-- Same established-stand assumption as calendar adoption, without
-- mutating a failed transaction or treating a seedling as mature.
if m:get_string("succ_birth_at")~="" or m:get_string("succ_lifecycle_profile")~="" then return false end
local k=kind(node(q))
return r.group~="trees" or family(k)=="mature_tree"
or (family(k)=="log" and m:get_string("succ_worldgen_stage")=="mature")
end
if m:get_string("succ_lifecycle_version")~="1" or m:get_string("succ_lifecycle_profile")~=r.id then return false end
local born=stored(m,"succ_birth_at",nil)
local initial=stored(m,"succ_initial_age_seconds",nil)
local high=stored(m,"succ_last_observed_at",nil)
if not born or not initial or not high or high<born or time<high or time<born then return false end
local phase=lifecycle.phase(profile,0,initial+time-born)
return phase and phase.phase=="mature" or false
end
local function resource_season(time)
local c=hearth.climate_at and hearth.climate_at(time)
return c and c.season or (hearth.seasons and hearth.seasons.season(time))
end
function S.pollinate(raw)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time or not botany then return false end
local n,m=resource_site(p)
local r=n and botany.get(n)
local season=resource_season(time)
if not r or not botany.active(n,season) or not botany.pollinator(n,season) or not mature_resource(p,r,time) then return false end
local ready=stored(m,"succ_nectar_ready_at",0)
local pollen=stored(m,"succ_pollinated_until",0)
if not ready or not pollen or ready>time or pollen>time+DAY then return false end
m:set_string("succ_nectar_ready_at",tostring(time+3600))
-- Boolean shared credit: not an accumulating yield counter or seeds.
if pollen<=time then m:set_string("succ_pollinated_until",tostring(time+DAY)) end
return true
end
function S.frugivore(raw,species_id)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time or not botany then return false end
local n,m=resource_site(p)
local r=n and botany.get(n)
local season=resource_season(time)
if not r or (species_id~=nil and species_id~=r.id) or r.group~="shrubs"
or not botany.active(n,season) or not botany.edible(n,season) or not mature_resource(p,r,time) then return false end
local ready=stored(m,"botany_ready_at",0)
if not ready or ready>time then return false end
-- Eat one unit, discard the rest of the existing two-fruit batch.
-- Share the human lock, never create a second animal-only reserve.
m:set_string("botany_ready_at",tostring(time+YEAR))
return true
end
function S.granivore(raw,species_id)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time or not botany then return false end
local n,m=resource_site(p)
local r=n and botany.get(n)
local season=resource_season(time)
if not r or (species_id~=nil and species_id~=r.id) or (r.id~="oak" and r.id~="hazel")
or season~="Autumn" or not botany.seeding(n,season) or not mature_resource(p,r,time) then return false end
local rootkind=family(kind(n))
if rootkind~="mature_tree" and not (rootkind=="log" and m:get_string("succ_worldgen_stage")=="mature") then return false end
local ok,date=pcall(os.date,"!*t",time)
local year=ok and type(date)=="table" and date.year or nil
if not valid_clock(year) then return false end
local previous=stored(m,"succ_nuts_year",0)
local remaining=stored(m,"succ_nuts_remaining",2,2)
if not previous or not remaining or previous%1~=0 or remaining%1~=0 or previous>year then return false end
if previous<year then remaining=2 end -- current crop only, no missed-year replay
if remaining<1 then return false end
local cache=remaining>=2
m:set_string("succ_nuts_year",tostring(year))
m:set_string("succ_nuts_remaining",tostring(remaining-(cache and 2 or 1)))
if cache then cache_nut={id=r.id,time=time,source=p}; return true,r.id end
return true
end
function S.disperse_seed(raw,species_id)
local paid=cache_nut; cache_nut=nil -- every attempt spends it, even failure
local p,time=position(raw),resource_now()
if not paid or not p or not time or paid.time~=time or paid.id~=species_id then return false end
-- Immediate local handoff only. Recheck the donor without replenishing it.
if math.abs(p.x-paid.source.x)>2 or p.y~=paid.source.y or math.abs(p.z-paid.source.z)>2 then return false end
local donor=resource_site(paid.source)
if not donor or botany.kind(donor)~=species_id then return false end
local n=node(p); local below=at(p,0,-1,0)
if not n or n.name~="air" or not node(below) or not ground(below)
or not permitted(p) or not permitted(below) then return false end
local m=api.get_meta(p)
local ready=stored(m,"succ_next",0)
if not ready or ready>time then return false end
if not change(p,species_id.."_seedling") then return false end
m:set_string("succ_next",tostring(time+DAY))
return true
end
function S.detritivore(raw)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time then return false end
local n,m,below=resource_site(p)
if not n or kind(n)~="litter" then return false end
local remaining=stored(m,"succ_litter_remaining",1,1)
local fertility=stored(api.get_meta(below),"succ_nutrients",0,8)
if not remaining or remaining<0.099999 or not fertility or fertility>7.9 then return false end
local last=remaining<0.100001
-- Final removal must preflight any dependent fungus BEFORE any debit.
if last and not change(p,nil) then return false end
m:set_string("succ_litter_remaining",tostring(last and 0 or remaining-0.1))
api.get_meta(below):set_string("succ_nutrients",tostring(fertility+0.1))
return true
end
function S.forage(raw,mode)
cache_nut=nil
local p = position(raw)
if not p or not node(p) or not permitted(p) then return false end
local time=resource_now()
if not p or not time or not node(p) or not permitted(p) then return false end
return nearby(p,function(q)
local n = node(q)
local n,m = resource_site(q)
local k=kind(n)
if not natural(q,n) then return false end
if not n then return false end
if botany and botany.kind(k) then
if not botany.browse(k,hearth.seasons and hearth.seasons.season() or "Summer")
or (trees[k] and family(k)~="tree_seedling") then return false end
local season=hearth.seasons and hearth.seasons.season() or "Summer"
local ordinary=botany.browse(k,season)
local seasonal=false
if (mode=="seasonal_browse" or mode=="winter_browse")
and (season=="Autumn" or season=="Winter") then
local profile=botany.get(k)
seasonal=profile and profile.tags.browse and not profile.toxic
and (profile.group=="trees" or profile.group=="shrubs" or profile.group=="grasses")
end
local stage=family(k)
local tree_identity=diversity and diversity.tree_info(k)
local living_tree=tree_identity~=nil or trees[k]
if (not ordinary and not seasonal) or (living_tree and stage~="tree_seedling") then return false end
elseif diversity then if not diversity.edible(k) then return false end
elseif k~="meadow_grass" and k~="fern" then return false end
if hearth.seasons and not hearth.seasons.available(k) then return false end
if not change(q,nil) then return false end
api.get_meta(q):set_string("succ_next",tostring(os.time()+300))
if mode~="seasonal_browse" and mode~="winter_browse"
and hearth.seasons and not hearth.seasons.available(k) then return false end
local ready=stored(m,"succ_browse_ready_at",0)
if not ready or ready>time then return false end
-- One partial canopy ration, not destruction of the whole perennial.
-- Seven real days is a conservative game fallback, not measured growth.
m:set_string("succ_browse_ready_at",tostring(time+7*DAY))
return true
end)
end
function S.deposit(raw)
cache_nut=nil
local p = position(raw)
if not p or not node(p) or not permitted(p) then return false end
return nearby(p,function(q)
@@ -743,7 +1036,7 @@ return function(hearth, api)
if hearth.seasons then
for _, name in ipairs(hearth.seasons.names()) do reload_nodes[#reload_nodes+1]=name end
end
api.register_lbm({name="hearth:succession_loaded", label="Pause natural succession across unloading",
api.register_lbm({name="hearth:succession_loaded", label="Reconcile calendar roots within the loaded work budget",
nodenames=reload_nodes, run_at_every_load=true,
action=function(p)
seen[key(p)]=nil
File diff suppressed because one or more lines are too long
+924 -223
View File
File diff suppressed because it is too large Load Diff