Lots of content

This commit is contained in:
2026-11-03 12:41:09 -05:00
parent 6ebe95b208
commit d34388bf2c
126 changed files with 28076 additions and 1027 deletions
+39
View File
@@ -0,0 +1,39 @@
-- Pure claim relationship checks. Unknown roles/actions and ambiguous overlaps deny.
return function(claims, access)
local function owner_at(pos)
local found
for owner, center in pairs(claims) do
if math.abs(pos.x - center.x) <= 12 and math.abs(pos.z - center.z) <= 12
and math.abs(pos.y - center.y) <= 24 then
if found and found ~= owner then return nil, true end
found = owner
end
end
return found, false
end
local function role_at(pos, name)
local owner, ambiguous = owner_at(pos)
if ambiguous or not owner then return nil end
if owner == name then return "owner" end
return access[owner] and access[owner][name] or nil
end
local function allowed(pos, name, action)
if type(pos) ~= "table" or type(pos.x) ~= "number" or type(pos.y) ~= "number"
or type(pos.z) ~= "number" or type(name) ~= "string" or name == "" then return false end
action = action or "build"
if action ~= "build" and action ~= "dig" and action ~= "use"
and action ~= "storage" and action ~= "arrival" and action ~= "combat" then return false end
local owner, ambiguous = owner_at(pos)
if ambiguous then return false end
if not owner then return true end
if owner == name then return true end
local role = access[owner] and access[owner][name]
if role == "resident" then return action ~= "arrival" and action ~= "combat" end
if role == "storekeeper" then return action == "storage" end
return false
end
return {owner_at = owner_at, role_at = role_at, allowed = allowed}
end
+322
View File
@@ -0,0 +1,322 @@
-- Original terrain content. Registration only: never rewrites generated land,
-- changes world settings, clears registries, or owns ecological growth timers.
return function(hearth, api, register_node, construction)
assert(type(hearth) == "table" and type(api) == "table")
assert(type(register_node) == "function" and type(construction) == "table")
local M = {}
local function node(name) return "hearth:" .. name end
local function tile(name) return "hearth_" .. name .. ".png" end
local function names(list)
local out = {}
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.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.
M.surfaces = {
grass = {moisture=0.45, fertility=0.7, drainage=0.7, vegetated=true},
forest_floor = {moisture=0.6, fertility=0.9, drainage=0.6, vegetated=true},
podzol = {moisture=0.4, fertility=0.35, drainage=0.8, vegetated=true},
alluvium = {moisture=0.85, fertility=1, drainage=0.3, vegetated=true},
peat = {moisture=1, fertility=0.3, drainage=0.1, vegetated=true},
alpine_turf = {moisture=0.35, fertility=0.3, drainage=0.9, vegetated=true},
soil = {moisture=0.45, fertility=0.5, drainage=0.6, vegetated=true},
sand = {moisture=0.2, fertility=0.1, drainage=1},
gravel = {moisture=0.2, fertility=0.1, drainage=1},
scree = {moisture=0.1, fertility=0.05, drainage=1},
snow = {moisture=0.1, fertility=0, drainage=0.5},
}
for _, rock in ipairs(M.rocks) do
M.surfaces[rock] = {moisture=0.1, fertility=0, drainage=1}
end
M.surfaces.stone = {moisture=0.1, fertility=0, drainage=1}
function M.surface(value)
local name = type(value) == "table" and value.name or value
if type(name) ~= "string" then return nil end
return M.surfaces[(name:gsub("^hearth:", ""))]
end
M.profiles = {
oak = {height=7, width=7, crown="broad", heat={35,80}, humidity={30,75},
y_min=3, y_max=100, soils={"grass", "forest_floor"},
biomes={"hearth_oak_woodland", "hearth_temperate"}, shade=0.8, litter=0.9},
birch = {height=8, width=5, crown="slender", heat={20,65}, humidity={35,80},
y_min=6, y_max=145, soils={"forest_floor", "grass", "podzol"},
biomes={"hearth_birch_forest"}, shade=0.45, litter=0.7},
pine = {height=9, width=7, crown="conical", heat={0,45}, humidity={15,70},
y_min=8, y_max=175, soils={"podzol", "alpine_turf"},
biomes={"hearth_pine_taiga"}, shade=0.65, litter=0.45},
willow = {height=7, width=9, crown="drooping", heat={35,85}, humidity={70,100},
y_min=2, y_max=8, soils={"alluvium", "peat"},
biomes={"hearth_floodplain"}, shade=0.85, litter=0.8, riparian=true},
alder = {height=6, width=5, crown="riparian", heat={20,65}, humidity={75,100},
y_min=2, y_max=45, soils={"alluvium", "peat", "forest_floor"},
biomes={"hearth_wet_forest", "hearth_floodplain"}, shade=0.6, litter=1,
riparian=true, nitrogen_fixer=true},
beech = {height=8, width=9, crown="domed", heat={35,70}, humidity={35,70},
y_min=4, y_max=115, soils={"forest_floor", "grass"},
biomes={"hearth_oak_woodland", "hearth_temperate"}, shade=0.95, litter=1},
aspen = {height=9, width=5, crown="columnar", heat={20,65}, humidity={35,75},
y_min=5, y_max=140, soils={"grass", "forest_floor", "alluvium"},
biomes={"hearth_birch_forest", "hearth_temperate"}, shade=0.45, litter=0.7},
rowan = {height=6, width=7, crown="open", heat={10,55}, humidity={30,80},
y_min=10, y_max=180, soils={"podzol", "alpine_turf", "forest_floor", "grass"},
biomes={"hearth_birch_forest", "hearth_pine_taiga", "hearth_alpine_meadow"}, shade=0.4, litter=0.6},
hazel = {height=5, width=7, crown="coppice", heat={35,75}, humidity={35,75},
y_min=3, y_max=95, soils={"forest_floor", "grass", "soil"},
biomes={"hearth_oak_woodland", "hearth_temperate"}, shade=0.7, litter=0.8},
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},
}
function M.supports_species(species, surface)
local p = M.profiles[species]
local name = type(surface) == "table" and surface.name or surface
if not p or type(name) ~= "string" then return false end
name = name:gsub("^hearth:", "")
for _, soil in ipairs(p.soils) do if soil == name then return true end end
return false
end
-- x fastest, then y, then z. Empty cells have probability zero, so these
-- bounded full-block schematics cannot carve slopes or erase other trees.
function M.tree_schematic(species, canonical, stage)
local p = assert(M.profiles[species], "unknown tree species")
assert(stage == nil or stage == "mature" or stage == "young", "unknown tree stage")
local w, h = p.width, p.height
if stage == "young" then w, h = 3, (species == "pine" or species == "spruce") and 5 or 4 end
local c, data = math.floor(w / 2), {}
for z = 0, w - 1 do for y = 0, h - 1 do for x = 0, w - 1 do
local dx, dz = math.abs(x-c), math.abs(z-c)
local r = math.max(dx, dz)
local trunk = dx == 0 and dz == 0 and y < h-1
local leaf = false
if stage == "young" then
leaf = y >= h-3 and dx+dz <= (y == h-1 and 0 or 1)
elseif species == "oak" then
leaf = y >= 3 and r <= (y == h-1 and 1 or (y == 3 and 2 or 3))
and dx+dz <= (y == h-1 and 2 or 4)
trunk = trunk or (y == 3 and dx+dz <= 2 and (dx == 0 or dz == 0))
elseif species == "birch" then
leaf = y >= 4 and dx+dz <= (y == h-1 and 1 or 2)
elseif species == "pine" then
local radius = ({[2]=3,[3]=2,[4]=2,[5]=1,[6]=1,[7]=1,[8]=0})[y]
leaf = radius ~= nil and r <= radius and dx+dz <= radius+1
elseif species == "willow" then
leaf = (y >= 4 and r <= (y == h-1 and 2 or 3) and dx+dz <= 5)
or (y >= 1 and y <= 4 and r == 3 and (dx+dz)%2 == 0)
trunk = trunk or (y == 4 and dx+dz <= 3 and (dx == 0 or dz == 0))
elseif species == "alder" then
leaf = y >= 2 and dx+dz <= (y == h-1 and 1 or 3)
elseif species == "beech" then
leaf = y >= 3 and r <= (y == h-1 and 2 or 4) and dx+dz <= (y == h-1 and 3 or 6)
trunk = trunk or (y == 3 and dx+dz <= 3 and (dx == 0 or dz == 0))
elseif species == "aspen" then
leaf = y >= 3 and r <= (y == h-1 and 1 or 2) and dx+dz <= 2
elseif species == "rowan" then
leaf = y >= 2 and dx+dz <= (y == h-1 and 1 or 3) and not (dx == dz and r == 2)
trunk = trunk or (y == 2 and dx+dz == 1)
elseif species == "hazel" then
leaf = y >= 1 and dx+dz <= (y == h-1 and 2 or 4)
trunk = trunk or (y <= 2 and dx+dz == 1)
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
end
local stem = canonical and "log" or species .. "_log"
local leaves = canonical and "leaves" or species .. "_leaves"
data[#data+1] = {name=trunk and node(stem) or (leaf and node(leaves) or "air"),
prob=(trunk or leaf) and 255 or 0, param2=0}
end end end
return {size={x=w, y=h, z=w}, data=data}
end
-- Heat/humidity are Voronoi centers, not hard climate bounds. Altitude bands
-- are hard bounds; all climate points converge on one permanent snow biome.
M.biome_profiles = {
{name="hearth_temperate", top="grass", filler="soil", rock="limestone", heat=52, humidity=45, min=2, max=110},
{name="hearth_oak_woodland", top="forest_floor", filler="soil", rock="limestone", heat=65, humidity=62, min=3, max=110},
{name="hearth_birch_forest", top="forest_floor", filler="soil", rock="granite", heat=36, humidity=58, min=6, max=150},
{name="hearth_pine_taiga", top="podzol", filler="soil", rock="granite", heat=18, humidity=38, min=2, max=180},
{name="hearth_wet_forest", top="peat", filler="alluvium", rock="slate", heat=42, humidity=90, min=2, max=65},
{name="hearth_floodplain", top="alluvium", filler="alluvium", rock="sandstone", heat=68, humidity=90, min=2, max=8},
{name="hearth_dry_scrub", top="grass", filler="soil", rock="sandstone", heat=83, humidity=18, min=2, max=135},
{name="hearth_alpine_meadow", top="alpine_turf", filler="scree", rock="slate", heat=30, humidity=48, min=111, max=210},
{name="hearth_rocky_peaks", top="snow", filler="scree", rock="granite", heat=25, humidity=45, min=211, max=31000},
{name="hearth_riverbank", top="sand", filler="gravel", rock="sandstone", heat=50, humidity=50, min=-31000, max=1},
}
local registered = false
function M.register()
if registered then return M end
-- Fail closed on an integration collision instead of silently overriding
-- another owner's node definitions or publishing partly compatible art.
local function add(name, def)
assert(not (api.registered_nodes or {})[node(name)], "duplicate terrain node: " .. name)
def.tiles = def.tiles or {tile(name)}
register_node(name, def)
end
for _, rock in ipairs(M.rocks) do
add(rock, {description=rock:gsub("^%l", string.upper) .. " bedrock",
groups={cracky=3, stone=1, hearth_geology=1}})
add(rock .. "_bricks", {description=rock:gsub("^%l", string.upper) .. " masonry",
-- Brick uses a derived world texture, not an inventory silhouette.
groups={cracky=3, stone=1, hearth_building=1},
on_place=construction.place_building, after_place_node=construction.owner})
api.register_craft({type="shapeless", output=node(rock .. "_bricks") .. " 4", recipe={node(rock)}})
-- A pair preserves mass without shadowing the single-rock brick recipe.
api.register_craft({type="shapeless", output=node("stone") .. " 2", recipe={node(rock), node(rock)}})
end
for _, soil in ipairs({"forest_floor", "podzol", "alluvium", "peat", "alpine_turf"}) do
add(soil, {description=soil:gsub("_", " "):gsub("^%l", string.upper),
tiles={tile(soil), tile("soil"), tile("soil")},
groups={crumbly=3, soil=1, hearth_habitat=1}, drop=node("soil")})
end
for _, loose in ipairs({"gravel", "scree", "snow"}) do
add(loose, {description=loose:gsub("^%l", string.upper),
groups={crumbly=3, falling_node=loose == "snow" and nil or 1},
drop=loose == "snow" and node("snow") or node("loose_stone")})
end
for _, species in ipairs(M.species_order) do
local title = species:gsub("^%l", string.upper)
add(species .. "_log", {description=title .. " log (requires an axe)", paramtype2="facedir",
tiles={tile(species .. "_log_top"), tile(species .. "_log_top"), tile(species .. "_log_side")},
groups={tree=1, choppy=2, flammable=2, hearth_canopy=1}})
add(species .. "_leaves", {description=title .. " leaves", drawtype="allfaces_optional",
paramtype="light", sunlight_propagates=true, use_texture_alpha="clip", waving=1,
groups={snappy=3, leaves=1, flammable=2, hearth_canopy=1},
-- Species recruitment/seed nodes belong to the ecosystem module.
drop=node("sticks")})
add(species .. "_planks", {description=title .. " planks",
groups={choppy=3, wood=1, flammable=2}})
api.register_craft({type="shapeless", output=node(species .. "_planks") .. " 4", recipe={node(species .. "_log")}})
-- Separate slots, not a counted stack: one log must still split to planks.
api.register_craft({type="shapeless", output=node("log") .. " 2", recipe={node(species .. "_log"), node(species .. "_log")}})
api.register_craft({type="shapeless", output=node("planks"), recipe={node(species .. "_planks")}})
end
for _, resource in ipairs({{"quartz", "quartz_vein", "Quartz crystal"},
{"copper_ore", "copper_ore_vein", "Copper ore"}, {"coal", "coal_ore_vein", "Bituminous coal"}}) do
assert(not (api.registered_items or {})[node(resource[1])], "duplicate resource item")
api.register_craftitem(node(resource[1]), {description=resource[3],
inventory_image="hearth_item_" .. resource[1] .. ".png"})
add(resource[2], {description=resource[3] .. " seam (requires a pick)",
groups={cracky=3, stone=1}, drop=node(resource[1]) .. " 2"})
end
-- Non-forcing source aliases only. Existing game/mapgen bindings win.
for _, alias in ipairs({{"mapgen_stone", "stone"}, {"mapgen_water_source", "water_source"},
{"mapgen_river_water_source", "water_source"}}) do
if not (api.registered_aliases or {})[alias[1]] and not (api.registered_items or {})[alias[1]] then
api.register_alias(alias[1], node(alias[2]))
end
end
for _, p in ipairs(M.biome_profiles) do
api.register_biome({name=p.name, node_top=node(p.top), depth_top=1,
node_filler=node(p.filler), depth_filler=p.name == "hearth_rocky_peaks" and 1 or 3,
node_stone=node(p.rock), node_water=node("water_source"),
node_river_water=node("water_source"), node_riverbed=node("gravel"), depth_riverbed=2,
y_min=p.min, y_max=p.max, heat_point=p.heat, humidity_point=p.humidity,
vertical_blend=0})
end
-- Ordered geological replacement precedes ores: coherent deep intrusions,
-- then nested ore clusters restricted to their actual host lithologies.
-- v7 reselects biomes at lower altitude limits. Below the riverbank band
-- the initial host is sandstone, NOT the surface woodland's node_stone.
-- Explicit beds keep all deep resources reachable in that real engine case.
for _, bed in ipairs({{"granite", -31000, -80}, {"slate", -79, -24}, {"limestone", -23, -8}}) do
api.register_ore({name="hearth:" .. bed[1] .. "_bed", ore_type="stratum", ore=node(bed[1]),
-- A zero cluster size is valid for strata and avoids Ore::placeOre
-- skipping a one-node slice where a bed meets a mapchunk boundary.
wherein=names({"sandstone", "stone"}), y_min=bed[2], y_max=bed[3], clust_size=0,
clust_scarcity=1})
end
api.register_ore({name="hearth:basalt_intrusions", ore_type="blob", ore=node("basalt"),
wherein=names({"granite", "stone", "slate", "limestone", "sandstone"}), clust_scarcity=48*48*48,
clust_size=24, y_min=-31000, y_max=-24, noise_threshold=0.15,
noise_params={offset=0, scale=1, spread={x=96,y=64,z=96}, seed=7131, octaves=3, persistence=0.6}})
local function ore(name, hosts, distance, count, size, ymin, ymax)
api.register_ore({name="hearth:" .. name .. "_" .. tostring(ymax), ore_type="scatter",
ore=node(name), wherein=names(hosts), clust_scarcity=distance^3,
clust_num_ores=count, clust_size=size, y_min=ymin, y_max=ymax})
end
ore("quartz_vein", {"granite", "slate"}, 11, 6, 3, -31000, 160)
ore("copper_ore_vein", {"basalt", "limestone", "slate"}, 13, 5, 3, -31000, 32)
ore("coal_ore_vein", {"sandstone", "slate", "limestone"}, 10, 9, 3, -31000, 70)
ore("iron_ore_vein", {"basalt", "granite", "limestone", "stone"}, 11, 6, 3, -31000, -8)
ore("iron_ore_vein", {"basalt", "granite", "limestone", "stone"}, 19, 3, 2, -7, 85)
local surfaces = {}
for name in pairs(M.surfaces) do surfaces[#surfaces+1] = node(name) end
table.sort(surfaces)
local function deco(id, plant, ground, ratio, ymin, ymax, biomes, extra)
local def = {name="hearth:" .. id, deco_type="simple", place_on=ground,
sidelen=16, fill_ratio=ratio, y_min=ymin, y_max=ymax, biomes=biomes,
decoration=node(plant)}
for k, v in pairs(extra or {}) do def[k] = v end
api.register_decoration(def)
end
local wet = {"hearth_wet_forest", "hearth_floodplain", "hearth_riverbank"}
local woods = {"hearth_oak_woodland", "hearth_birch_forest", "hearth_pine_taiga", "hearth_wet_forest"}
local meadow = {"hearth_temperate", "hearth_dry_scrub", "hearth_alpine_meadow"}
local near_water = {spawn_by=names({"water_source", "water_flowing"}), num_spawn_by=1, check_offset=-1}
-- Establish real canopies before ground flora can occupy the trunk cell.
for _, species in ipairs(M.species_order) do
local p = M.profiles[species]
local def = {name="hearth:" .. species .. "_trees", deco_type="schematic", place_on=names(p.soils),
sidelen=16, fill_ratio=species == "willow" and 0.004 or 0.012,
y_min=p.y_min, y_max=p.y_max, biomes=p.biomes, schematic=M.tree_schematic(species),
rotation="random", flags="place_center_x,place_center_z", place_offset_y=1}
if p.riparian then
def.spawn_by, def.num_spawn_by, def.check_offset = near_water.spawn_by, 1, -1
end
api.register_decoration(def)
end
-- Canonical timber remains discoverable by legacy starting-resource probes.
api.register_decoration({name="hearth:temperate_trees", deco_type="schematic",
place_on=names({"grass"}), sidelen=16, fill_ratio=0.012,
y_min=3, y_max=110, biomes={"hearth_temperate"}, schematic=M.tree_schematic("oak", true),
rotation="random", flags="place_center_x,place_center_z", place_offset_y=1})
-- Sparse guaranteed-compatible gathering substrate, not guaranteed spawns.
-- No soil type can strand progression merely because grass is absent.
deco("loose_stone", "loose_stone", surfaces, 0.035, 1, 31000)
deco("sticks", "sticks", surfaces, 0.03, 1, 210)
deco("berry_bush", "berry_bush", names({"grass", "forest_floor", "podzol", "alluvium"}), 0.018, 2, 130)
deco("grain_patch", "grain_patch", names({"grass", "alluvium"}), 0.009, 2, 100,
{"hearth_temperate", "hearth_floodplain"})
-- Springs precede water-dependent plants: decorations see earlier writes,
-- so upland rushes can establish at the newly generated seep edges.
deco("seep_springs", "water_source", names({"grass", "forest_floor", "podzol"}), 0.0015, 3, 150,
{"hearth_temperate", "hearth_oak_woodland", "hearth_birch_forest", "hearth_pine_taiga"},
{place_offset_y=-1, flags="force_placement"})
deco("reeds", "reeds", names({"sand", "gravel", "alluvium", "peat", "grass", "forest_floor", "podzol"}), 0.09, 1, 150, nil, near_water)
deco("clay_bank", "clay_bank", names({"sand", "gravel", "alluvium"}), 0.04, -2, 8, wet, near_water)
deco("meadow_grasses", "meadow_grass", names({"grass", "alpine_turf"}), 0.15, 2, 210, meadow)
deco("meadow_flowers", "wildflowers", names({"grass", "alpine_turf"}), 0.025, 2, 210, meadow)
deco("woodland_ferns", "fern", names({"forest_floor", "peat", "alluvium"}), 0.06, 2, 145, woods)
deco("woodland_litter", "litter", names({"forest_floor", "podzol", "peat"}), 0.08, 2, 180, woods)
deco("woodland_fungi", "fungi", names({"forest_floor", "peat"}), 0.012, 2, 130, woods)
-- Three ordinary log cubes on their sides; top grain faces +X (12/4=3).
-- Random schematic rotation also permits Z-aligned logs. Never force-place.
api.register_decoration({name="hearth:woodland_deadwood", deco_type="schematic",
place_on=names({"forest_floor", "podzol", "peat"}), sidelen=16, fill_ratio=0.007,
y_min=2, y_max=160, biomes=woods, rotation="random", place_offset_y=1,
flags="place_center_x,place_center_z", schematic={size={x=3,y=1,z=1},data={
{name=node("deadwood"),prob=255,param2=12}, {name=node("deadwood"),prob=255,param2=12},
{name=node("deadwood"),prob=255,param2=12}}}})
deco("spring_foraging", "spring_greens_patch", names({"grass", "alluvium"}), 0.009, 2, 100,
{"hearth_temperate", "hearth_floodplain"})
deco("autumn_foraging", "autumn_mushrooms_patch", names({"forest_floor", "podzol"}), 0.012, 2, 145, woods)
-- Retain the old landmark contract without force-writing banks/water.
local weir = {size={x=7,y=3,z=5}, data={}}
for z=0,4 do for y=0,2 do for x=0,6 do
local edge = z == 0 or z == 4 or x == 0 or x == 6
local wall = edge and (y == 0 or ((z == 0 or z == 4) and y == 1)
or ((x == 0 or x == 6) and z >= 1 and z <= 3))
weir.data[#weir.data+1] = {name=wall and node("weir_stone") or "air", prob=wall and 255 or 0}
end end end
api.register_decoration({name="hearth:old_river_weir", deco_type="schematic",
place_on=names({"sand", "gravel", "alluvium"}), sidelen=16, fill_ratio=0.001,
y_min=0, y_max=8, biomes=wet, schematic=weir, rotation="random",
flags="place_center_x,place_center_z", place_offset_y=1,
spawn_by=near_water.spawn_by, num_spawn_by=1, check_offset=-1})
registered = true
return M
end
return M
end
+359
View File
@@ -0,0 +1,359 @@
-- Temperate NW-European catalogue and read-only niche policy. No scheduler/map writes.
-- Registration uses the content registrar's BARE-name convention.
return function(hearth, api)
local B = {groups={"trees","shrubs","grasses","herbs","ferns_mosses","fungi","aquatic","lichens"}}
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(text)
local r={}; for s in text:gmatch("[^,]+") do r[s]=true end; return r
end
local records, index, seasonal_nodes = {}, {}, {}
local soil="grass,soil,forest_floor,alluvium"
local acid="grass,soil,forest_floor,podzol,peat,alpine_turf"
local wet="alluvium,peat,soil,grass"
local rock="stone,granite,basalt,limestone,slate,sandstone,scree"
local year="Spring,Summer,Autumn,Winter"
local grow="Spring,Summer,Autumn"
local profiles={
wood={soil,4,15,.3,.85,4.5,7.5},
acid={acid,8,15,.2,.7,3.5,6.5},
wetwood={wet,6,15,.65,1,4,7.5},
shrub={soil,6,15,.3,.8,4.5,7.5},
meadow={"grass,soil,alluvium",9,15,.3,.8,5,7.5},
bank={wet,7,15,.75,1,4.5,8},
shade={"forest_floor,soil,alluvium",3,11,.45,.9,4.5,7.5},
moss={acid,2,10,.6,1,3.5,7},
litter={"forest_floor,litter",2,12,.45,.85,4,7.5},
deadwood={"deadwood",2,13,.4,.95,3.5,7.5},
water={"alluvium,soil,peat,sand",7,15,.8,1,5,8.5},
bark={"bark",5,13,.4,.9,4,7},
lichenrock={rock,9,15,.1,.6,3.5,7.5},
}
-- Each row owns exactly one group. Interactions describe ecology, not edibility.
local group
local function add(id,common,latin,profile,seasons,tags,extra)
local p=assert(profiles[profile])
local r={id=id,common=common,latin=latin,group=group,seasons=set(seasons),tags=set(tags),
niche={substrates=set(p[1]),light={p[2],p[3]},moisture={p[4],p[5]},ph={p[6],p[7]},
aquatic="land",water={0,0}},toxic=false,food=false,existing=false}
for k,v in pairs(extra or {}) do r[k]=v end
r.node="hearth:"..id..(group=="trees" and "_seedling" or "")
r.texture="hearth_botany_"..id..".png"
records[#records+1]=r; assert(not index[id],id); index[id]=r
end
group="trees"
add("oak","Pedunculate oak","Quercus robur","wood",grow,"browse,ectomycorrhiza,nursery",{existing=true})
add("birch","Silver birch","Betula pendula","acid",grow,"browse,ectomycorrhiza,nursery",{existing=true})
add("pine","Scots pine","Pinus sylvestris","acid",year,"ectomycorrhiza,nursery,resin",{existing=true})
add("willow","White willow","Salix alba","wetwood",grow,"pollinator,browse,ectomycorrhiza,engineer",{existing=true})
add("alder","Common alder","Alnus glutinosa","wetwood",grow,"nitrogenfixer,ectomycorrhiza,engineer",{existing=true})
add("beech","European beech","Fagus sylvatica","wood",grow,"browse,ectomycorrhiza,nursery")
add("aspen","European aspen","Populus tremula","wood",grow,"browse,ectomycorrhiza,nursery")
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")
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})
add("raspberry","Red raspberry","Rubus idaeus","shrub",grow,"pollinator,browse,nursery",{food=true})
add("elder","Black elder","Sambucus nigra","shrub",grow,"pollinator,browse,nursery",{toxic=true})
add("blackthorn","Blackthorn","Prunus spinosa","shrub",grow,"pollinator,browse,nursery")
add("hawthorn","Common hawthorn","Crataegus monogyna","shrub",grow,"pollinator,browse,nursery")
add("dog_rose","Dog rose","Rosa canina","shrub",grow,"pollinator,browse,nursery")
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})
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})
add("meadow_fescue","Meadow fescue","Schedonorus pratensis","meadow",grow,"browse,engineer")
add("cocksfoot","Cock's-foot","Dactylis glomerata","meadow",grow,"browse,engineer")
add("timothy","Timothy","Phleum pratense","meadow",grow,"browse,engineer")
add("sweet_vernal_grass","Sweet vernal-grass","Anthoxanthum odoratum","acid",grow,"browse,engineer")
add("yorkshire_fog","Yorkshire-fog","Holcus lanatus","meadow",grow,"browse,engineer")
add("tufted_hairgrass","Tufted hair-grass","Deschampsia cespitosa","bank",grow,"browse,engineer,nursery")
add("common_reed","Common reed","Phragmites australis","bank",grow,"engineer,nursery")
add("soft_rush","Soft rush","Juncus effusus","bank",grow,"engineer,nursery")
group="herbs"
add("clover","White clover","Trifolium repens","meadow",grow,"nitrogenfixer,pollinator,browse",{existing=true})
add("nettle","Common nettle","Urtica dioica","shade",grow,"browse,nursery",{existing=true,toxic=true})
add("heather","Heather","Calluna vulgaris","acid",grow,"pollinator,browse,nursery",{existing=true})
add("bluebell","English bluebell","Hyacinthoides non-scripta","shade","Spring","pollinator",{existing=true,toxic=true})
add("yarrow","Yarrow","Achillea millefolium","meadow",grow,"pollinator")
add("foxglove","Common foxglove","Digitalis purpurea","acid",grow,"pollinator",{toxic=true})
add("meadowsweet","Meadowsweet","Filipendula ulmaria","bank",grow,"pollinator,nursery")
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})
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")
add("male_fern","Male-fern","Dryopteris filix-mas","shade",grow,"nursery",{toxic=true})
add("harts_tongue","Hart's-tongue fern","Asplenium scolopendrium","shade",year,"nursery")
add("polypody","Common polypody","Polypodium vulgare","shade",year,"nursery")
add("sphagnum","Blunt-leaved bog-moss","Sphagnum palustre","moss",year,"engineer,nursery")
add("haircap_moss","Common haircap","Polytrichum commune","moss",year,"engineer,nursery")
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")
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")
add("bay_bolete","Bay bolete","Imleria badia","litter","Summer,Autumn","ectomycorrhiza")
add("chanterelle","Chanterelle","Cantharellus cibarius","litter","Summer,Autumn","ectomycorrhiza")
add("wood_blewit","Wood blewit","Collybia nuda","litter","Autumn,Winter","saprotroph")
add("shaggy_inkcap","Shaggy inkcap","Coprinus comatus","meadow","Summer,Autumn","saprotroph")
add("turkey_tail","Turkey tail","Trametes versicolor","deadwood",year,"saprotroph")
add("oyster_fungus","Oyster mushroom","Pleurotus ostreatus","deadwood","Autumn,Winter","saprotroph")
add("honey_fungus","Honey fungus","Armillaria mellea","deadwood","Autumn","saprotroph,parasite",{toxic=true})
add("birch_polypore","Birch polypore","Fomitopsis betulina","deadwood",year,"saprotroph,parasite")
group="aquatic"
add("duckweed","Common duckweed","Lemna minor","water",grow,"nursery,engineer")
add("yellow_waterlily","Yellow water-lily","Nuphar lutea","water",grow,"pollinator,nursery",{toxic=true})
add("white_waterlily","White water-lily","Nymphaea alba","water",grow,"pollinator,nursery")
add("water_crowfoot","Common water-crowfoot","Ranunculus aquatilis","water",grow,"pollinator,nursery",{toxic=true})
add("water_mint","Water mint","Mentha aquatica","bank",grow,"pollinator,nursery")
add("water_forgetmenot","Water forget-me-not","Myosotis scorpioides","bank",grow,"pollinator,nursery")
add("brooklime","Brooklime","Veronica beccabunga","bank",grow,"pollinator,nursery")
add("water_starwort","Common water-starwort","Callitriche stagnalis","water",grow,"nursery")
add("hornwort","Rigid hornwort","Ceratophyllum demersum","water",grow,"nursery,engineer")
add("pondweed","Broad-leaved pondweed","Potamogeton natans","water",grow,"nursery")
group="lichens"
add("reindeer_lichen","Reindeer lichen","Cladonia rangiferina","acid",year,"browse,nursery")
add("oakmoss","Oakmoss","Evernia prunastri","bark",year,"nursery")
add("old_mans_beard","Old man's beard","Usnea subfloridana","bark",year,"nursery")
add("lichen","Map lichen","Rhizocarpon geographicum","lichenrock",year,"engineer",{existing=true})
add("dog_lichen","Dog lichen","Peltigera canina","moss",year,"nitrogenfixer,engineer")
add("lungwort_lichen","Lungwort lichen","Lobaria pulmonaria","bark",year,"nitrogenfixer,nursery")
add("sunburst_lichen","Common orange lichen","Xanthoria parietina","bark",year,"engineer")
add("hammered_shield","Hammered shield lichen","Parmelia sulcata","bark",year,"nursery")
add("cup_lichen","Mealy pixie-cup","Cladonia chlorophaea","acid",year,"engineer")
add("pixie_cup","Pebbled pixie-cup","Cladonia pyxidata","acid",year,"engineer")
-- Species-level refinements; ranges are game niche windows, not field measurements.
local function niche(id,changes) for k,v in pairs(changes) do index[id].niche[k]=v end end
niche("beech",{moisture={.3,.7},ph={5,8}})
niche("rowan",{light={5,15},moisture={.3,.85}})
niche("spruce",{moisture={.4,.85},light={4,15}})
niche("hazel",{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}})
niche("harts_tongue",{ph={6,8}})
niche("polypody",{substrates=set("forest_floor,soil,bark,stone,granite,slate"),ph={4,7}})
niche("sphagnum",{substrates=set("peat,alluvium"),moisture={.85,1},ph={3.5,6}})
niche("haircap_moss",{light={4,15},ph={3.5,6}})
niche("moss",{substrates=set(acid..",bark,deadwood,"..rock)})
niche("dog_lichen",{light={5,13},moisture={.4,.85},ph={5,8}})
niche("lungwort_lichen",{light={3,10},moisture={.65,1},ph={5,7.5}})
niche("sunburst_lichen",{substrates=set("bark,limestone,stone"),light={9,15},moisture={.2,.75},ph={5.5,8.5}})
niche("lichen",{substrates=set("stone,granite,slate,sandstone,scree"),ph={3.5,6.5}})
local hosts={fly_agaric="birch,pine,spruce",birch_bolete="birch",bay_bolete="pine,spruce,beech,oak",
chanterelle="oak,beech,birch,pine,spruce",birch_polypore="birch",
turkey_tail="oak,birch,willow,alder,beech,aspen,rowan,hazel",
oyster_fungus="beech,willow,aspen,oak,birch",honey_fungus="oak,birch,willow,alder,beech,aspen,rowan,hazel,spruce"}
for id,text in pairs(hosts) do niche(id,{hosts=set(text)}) end
for _,id in ipairs({"fly_agaric","birch_bolete","bay_bolete","chanterelle"}) do
niche(id,{substrates=set("forest_floor,soil,podzol"),ph={3.5,6.5}})
end
for _,id in ipairs({"duckweed","yellow_waterlily","white_waterlily","water_crowfoot","pondweed"}) do
niche(id,{aquatic="surface",water={1,id=="duckweed" and 6 or 3}})
end
for _,id in ipairs({"water_starwort","hornwort"}) do niche(id,{aquatic="submerged",water={1,4}}) end
for _,id in ipairs({"water_mint","water_forgetmenot","brooklime","sedge","bulrush","common_reed","soft_rush","tufted_hairgrass"}) do
niche(id,{aquatic="bank",water={0,0}})
end
index.hornwort.niche.substrates=set("water") -- rootless: never requires a mud anchor
local flowers={willow="Spring",rowan="Spring,Summer",bilberry="Spring,Summer",blackberry="Summer",
raspberry="Summer",elder="Summer",blackthorn="Spring",hawthorn="Spring",dog_rose="Summer",
holly="Spring,Summer",guelder_rose="Spring,Summer",clover="Spring,Summer",heather="Summer,Autumn",
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"}
for id,text in pairs(flowers) do index[id].flowering=set(text) end
for _,id in ipairs({"bilberry","blackberry","raspberry"}) do
index[id].fruiting=set(id=="blackberry" and "Summer,Autumn" or "Summer")
end
local descriptors={
nitrogenfixer={description="Symbionts fix atmospheric nitrogen; bounded soil contribution, never an item source.",cap=2},
browse={description="Wildlife forage or cover relation; not human food permission."},
pollinator={description="Flower-season pollen/nectar resource; not a year-round aura."},
ectomycorrhiza={description="Host-specific root exchange of existing mineral/water resources for tree carbon.",cap=1},
saprotroph={description="Decomposes existing litter or deadwood; any transfer must consume that substrate."},
parasite={description="Natural-history descriptor only; never damages player plants.",enabled=false},
engineer={description="Roots or mats retain substrate/water, or aquatic cover changes light; bounded local effect."},
nursery={description="Shelter/microhabitat for wildlife or recruits, conditional on appropriate habitat."},
allelopathy={description="Bracken chemical competition is context-dependent; descriptor only, no killing.",enabled=false},
resin={description="Resin-bearing conifer; resource extraction belongs to tree/resource policy."},
}
local function finite(n) return type(n)=="number" and n==n and math.abs(n)<math.huge end
local function raw_kind(value)
local name=type(value)=="table" and value.name or value
if type(name)~="string" then return nil end
if seasonal_nodes[name] then return seasonal_nodes[name] end
if hearth.seasons and hearth.seasons.kind then name=hearth.seasons.kind(name) or name end
return name:gsub("^hearth:","")
end
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]+)$")
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
local function record(value) local id=B.kind(value); return id and index[id] end
function B.get(value) return copy(record(value)) end
function B.group_of(value) local r=record(value); return r and r.group end
function B.list(g)
local out={}; for _,r in ipairs(records) do if not g or r.group==g then out[#out+1]=copy(r) end end; return out
end
function B.has_tag(value,tag) local r=record(value); return r~=nil and r.tags[tag]==true end
function B.interactions(value)
local r=record(value); local out={}
if r then for tag in pairs(r.tags) do out[tag]=copy(descriptors[tag]) end end
return out
end
function B.active(value,season) local r=record(value); return r~=nil and r.seasons[season]==true end
-- Install policy at factory time, before content calls seasons.register().
-- Diversity owns the eight existing understory IDs and every tree stage.
local seasons=hearth.seasons
if seasons and seasons.add_variants then
for _,r in ipairs(records) do
if r.group~="trees" and not r.existing then
local definitions={}
for _,season in ipairs({"Spring","Summer","Autumn","Winter"}) do
if not B.active(r.id,season) then
definitions[season]={texture="hearth_botany_"..r.id.."_dormant.png"}
seasonal_nodes[r.node.."_"..season:lower()]=r.id
end
end
-- A copy prevents optional consumers from mutating catalogue policy.
seasons.add_variants(r.id,definitions,copy(r.seasons))
end
end
end
function B.pollinator(value,season)
local r=record(value); return r~=nil and r.tags.pollinator==true and r.flowering[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
function B.browse(value,season) return B.active(value,season) and B.has_tag(value,"browse") end
function B.resin(value) return B.has_tag(value,"resin") end
-- Pure effect suggestions; caller owns metadata, nutrient budgets and protection.
function B.nitrogen(value,current)
if not B.has_tag(value,"nitrogenfixer") or not finite(current) or current<0 then return 0 end
return math.min(1,math.max(0,2-current))
end
function B.root_fungi(value,tree,budget)
local r,t=record(value),B.kind(tree)
if not r or r.group~="fungi" or not r.tags.ectomycorrhiza or not r.niche.hosts[t]
or not finite(budget) or budget<=0 then return 0 end
return math.min(1,budget) -- transfer ceiling, not newly created nutrients
end
local function within(n,range) return finite(n) and n>=range[1] and n<=range[2] end
function B.suitable(value,h,season)
local r=record(value)
if not r or type(h)~="table" or h.loaded~=true or not B.active(value,season) then return false end
local n=r.niche
if not within(h.light,n.light) or not within(h.moisture,n.moisture) or not within(h.ph,n.ph) then return false end
if n.nutrients and (not finite(h.nutrients) or h.nutrients<n.nutrients) then return false end
if n.hosts and not n.hosts[B.kind(h.host)] then return false end
local depth=h.water or 0
if not within(depth,n.water) then return false end
local ground=raw_kind(h.substrate or h.ground)
if n.aquatic=="surface" or n.aquatic=="submerged" then
if h.aquatic~=n.aquatic or h.freshwater~=true then return false end
if r.id=="hornwort" then return ground=="water" end
return n.substrates[raw_kind(h.ground)]==true
end
if h.aquatic and h.aquatic~="land" and h.aquatic~="bank" then return false end
if n.aquatic=="bank" and h.aquatic~="bank" then return false end
return n.substrates[ground]==true
end
function B.choose(p,h,season)
if type(p)~="table" or type(h)~="table" then return nil end
for _,axis in ipairs({"x","y","z"}) do if not finite(p[axis]) or math.abs(p[axis])>30990 or p[axis]%1~=0 then return nil end end
local start=math.abs(p.x*17+p.y*7+p.z*31)%#records
for i=1,#records do
local r=records[(start+i-1)%#records+1]
if (r.group~="trees" or h.include_trees==true) and B.suitable(r.id,h,season) then
return r.node:sub(8),copy(r)
end
end
end
local DAY=86400
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
return math.floor(n)
end
function B.harvest(pos,player)
if type(pos)~="table" then return false,"invalid" end
for _,a in ipairs({"x","y","z"}) do if not finite(pos[a]) or math.abs(pos[a])>30990 or pos[a]%1~=0 then return false,"invalid" end end
local construction=hearth.construction -- late lookup: content constructs this later
if not player or not player.is_player or not player:is_player() or not construction
or not construction.allowed or not construction.allowed(pos,player,"use") then return false,"denied" end
local node=api.get_node_or_nil(pos)
local r=node and record(node)
-- Canonicalization alone is not authority: accept only the base or an
-- exact seasonal name installed above, never arbitrary suffix aliases.
if not r or not r.food or (node.name~=r.node and seasonal_nodes[node.name]~=r.id) then return false,"species" end
local time=now(); if not time then return false,"clock" end
local climate=hearth.climate_at and hearth.climate_at(time)
if not climate or not B.active(r.id,climate.season) or not B.edible(r.id,climate.season) then return false,"season" end
local meta=api.get_meta(pos)
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 ready>time then return false,"cooldown" end
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")
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
end
local registered=false
function B.register_nodes(register_node)
if registered then return end
assert(type(register_node)=="function","botany needs a bare-name node registrar")
for _,r in ipairs(records) do
if r.group~="trees" and not r.existing and not (api.registered_nodes or {})[r.node] then
local id=r.id
local def={description=r.common.." ("..r.latin..")",drawtype="plantlike",tiles={r.texture},
inventory_image=r.texture,wield_image=r.texture,paramtype="light",sunlight_propagates=true,
use_texture_alpha="clip",walkable=false,buildable_to=false,
groups={snappy=3,flora=1,hearth_ecosystem=1},drop="hearth:"..id,
after_place_node=function(pos) api.get_meta(pos):set_string("player_placed","1") end}
-- A dug bush cannot reset its fruit cooldown by becoming a fresh item.
if r.group=="shrubs" then def.drop="hearth:sticks" end
if r.niche.aquatic=="surface" then
def.drawtype="nodebox"; def.node_box={type="fixed",fixed={-.45,-.49,-.45,.45,-.44,.45}}
def.selection_box=copy(def.node_box)
elseif r.niche.aquatic=="submerged" then
-- A sprite is NOT water: never give it liquid groups/source behavior.
-- External placement must retain a separate real water column.
def.groups.hearth_submerged=1
end
if r.niche.aquatic=="surface" or r.niche.aquatic=="submerged" then
-- Default item_place would replace buildable-to water. Safe external
-- ecology placement must reserve a distinct air cell instead.
def.on_place=function(stack) return stack end
end
if r.food then def.on_rightclick=function(pos,_,player,stack) B.harvest(pos,player); return stack end end
register_node(id,def)
end
end
registered=true
end
B.catalogue=B.list(); B.group_order=copy(B.groups)
return B
end
+227
View File
@@ -0,0 +1,227 @@
-- Cosmetic-only factory. No global access, world storage, identity or physics.
return function(hearth, api)
local C = {VERSION = 1, MAX_JSON = 2048, FORM = "hearth:character"}
local key = "hearth:appearance"
local options = {
body = {"compact", "balanced", "broad"},
skin = {"porcelain", "sand", "honey", "olive", "umber", "ebony"},
face = {"calm", "angular", "soft", "weathered"},
eyes = {"hazel", "brown", "green", "blue", "grey"},
hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none"},
hairColor = {"ash", "flax", "chestnut", "auburn", "black", "silver"},
beard = {"none", "stubble", "moustache", "short_beard", "full_beard"},
garment = {"tunic", "workshirt", "jerkin", "coat"},
cloth = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
trousers = {"earth", "slate", "moss", "charcoal"}, boots = {"walnut", "black", "tan"},
accent = {"none", "scarf", "apron", "sash", "vest"},
accentColor = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
detail = {"none", "freckles", "scar", "freckles_scar"},
}
local order = {"body", "skin", "face", "eyes", "hair", "hairColor", "beard", "garment",
"cloth", "trousers", "boots", "accent", "accentColor", "detail"}
local defaults = {version = 1, body = "balanced", skin = "sand", face = "calm", eyes = "hazel",
hair = "sidepart", hairColor = "chestnut", beard = "none", garment = "tunic", cloth = "teal",
trousers = "earth", boots = "walnut", accent = "scarf", accentColor = "sage", detail = "none"}
local function copy(t)
local out = {}; for k, v in pairs(t) do out[k] = v end; return out
end
function C.defaults() return copy(defaults) end
function C.options()
local out = {}; for k, list in pairs(options) do out[k] = copy(list) end; return out
end
local sets = {}
for k, list in pairs(options) do
sets[k] = {}; for _, id in ipairs(list) do sets[k][id] = true end
end
function C.validate(value)
if type(value) ~= "table" or getmetatable(value) ~= nil or value.version ~= 1 then return nil end
for k in pairs(value) do if k ~= "version" and not options[k] then return nil end end
local out = {version = 1}
for _, k in ipairs(order) do
local id = value[k]
if type(id) ~= "string" or #id > 24 or not sets[k][id] then return nil end
out[k] = id
end
return out
end
-- Reject nesting/arrays before invoking the engine parser (bounded stack as
-- well as bytes). The parser, not this scanner, validates JSON grammar.
local function flat_json(raw)
if type(raw) ~= "string" or #raw == 0 or #raw > C.MAX_JSON then return false end
local quoted, escaped, depth = false, false, 0
for i = 1, #raw do
local c = raw:sub(i,i)
if quoted then
if escaped then escaped = false
elseif c == "\\" then escaped = true
elseif c == '"' then quoted = false end
elseif c == '"' then quoted = true
elseif c == "[" or c == "]" then return false
elseif c == "{" then depth = depth + 1; if depth > 1 then return false end
elseif c == "}" then depth = depth - 1; if depth < 0 then return false end end
end
return not quoted and depth == 0
end
function C.decode(raw)
if not flat_json(raw) then return nil end
local ok, value = pcall(api.parse_json, raw, nil, true)
return ok and C.validate(value) or nil
end
function C.encode(value)
local valid = C.validate(value)
if not valid then return nil end
local ok, raw = pcall(api.write_json, valid)
if not ok or type(raw) ~= "string" or #raw > C.MAX_JSON then return nil end
local roundtrip = C.decode(raw)
if not roundtrip then return nil end
for k, v in pairs(valid) do if roundtrip[k] ~= v then return nil end end
return raw
end
function C.preview(value)
local p = C.validate(value)
if not p then return nil end
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)}, "^")
return "hearth_person" .. (p.body == "balanced" and "" or "_" .. p.body) .. ".b3d", texture
end
local function publish(player, value)
local mesh, texture = C.preview(value)
player:set_properties({mesh = mesh, textures = {texture}})
end
local function session(player)
if not player then return nil end
local name = player:get_player_name()
if api.get_player_by_name(name) ~= player then return nil end
return hearth.sessions[name]
end
local corrupt = "Saved appearance is unreadable. Showing defaults; original data is retained until you Apply."
local function load(player)
local ok, raw = pcall(function() return player:get_meta():get_string(key) end)
if ok and raw == "" then return C.defaults() end
local valid = ok and C.decode(raw)
if valid then return valid end
return C.defaults(), corrupt
end
function C.apply(player)
local s = session(player); if not s then return false end
local value, warning = load(player)
s.character = {player = player, saved = value, warning = warning}
publish(player, value)
if warning and hearth.notify then hearth.notify(player, warning) end
return true
end
C.on_join = C.apply
local tabs = {{"Face", "body", "skin", "face", "eyes"}, {"Hair", "hair", "hairColor", "beard"},
{"Clothing", "garment", "cloth", "trousers", "boots"}, {"Finishing", "accent", "accentColor", "detail"}}
local labels = {body = "Body shape (equal mechanics)", skin = "Skin tone", face = "Face lines", eyes = "Eyes",
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 function title(id)
return names[id] or (id:gsub("_", " "):gsub("^%l", string.upper))
end
local function state(player)
local s = session(player)
local st = s and s.character
return st and st.player == player and st or nil
end
function C.state(player)
local st = state(player)
return st and {saved = copy(st.saved), draft = st.draft and copy(st.draft), warning = st.warning,
tab = st.tab, yaw = st.yaw} or nil
end
local function render(player, st)
local esc = api.formspec_escape
local mesh, texture = C.preview(st.draft)
local f = {"formspec_version[6]size[14,11]bgcolor[#1e1e2e;true]",
"style_type[label,textarea;textcolor=#cdd6f4]style_type[button;bgcolor=#292940;textcolor=#cdd6f4]",
"label[0.6,0.5;THE LAST HEARTH / WOODLAND TRAVELER]",
"label[0.6,1;Quiet cloth, worn leather, a face of your own.]",
"box[0.5,1.5;5.4,7.6;#181825]",
-- lua_api.md: camera x,y; continuous rotation; mouse control; frames; FPS.
"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.]"}
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
for i = 2, #tabs[st.tab] do
local k, y = tabs[st.tab][i], 2.8 + (i-2)*1.45
local choices, selected = {}, 1
for j, id in ipairs(options[k]) do
choices[j] = esc(title(id)); if id == st.draft[k] then selected = j end
end
f[#f+1] = "label[6.3," .. y .. ";" .. esc(labels[k]) .. "]dropdown[6.3," .. (y+0.3)
.. ";7,0.7;pick_" .. k .. ";" .. table.concat(choices,",") .. ";" .. selected .. ";true]"
end
f[#f+1] = "textarea[6.2,8.7;7.2,0.8;;;" .. esc(st.warning or "Preview only. Nothing changes until Save / Apply.") .. "]"
f[#f+1] = "button[0.7,9.85;2.6,0.65;randomize;Randomize]button[3.5,9.85;2.6,0.65;reset;Reset draft]"
.. "button[7.1,9.85;2.7,0.65;cancel;Cancel]button[10,9.85;3.3,0.65;apply;Save / Apply]"
api.show_formspec(player:get_player_name(), C.FORM, table.concat(f))
end
function C.show(player)
if not session(player) then return false end
local st = state(player)
if not st then C.apply(player); st = state(player) end
st.draft, st.tab, st.yaw = copy(st.saved), 1, 180
render(player, st); return true
end
local controls = {quit = true, cancel = true, apply = true, reset = true, randomize = true,
front = true, profile = true, back = true, tab_1 = true, tab_2 = true, tab_3 = true, tab_4 = true}
function C.handle(player, fields)
local st = state(player)
if not st or not st.draft or type(fields) ~= "table" then return false end
-- No target name/ID is ever accepted. The engine-supplied PlayerRef owns
-- this draft, tied to the current session and connected object identity.
local visible, draft = {}, copy(st.draft)
for i = 2, #tabs[st.tab] do visible["pick_" .. tabs[st.tab][i]] = tabs[st.tab][i] end
local count = 0
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)
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")
or (type(v) == "string" and #v > 32) then return false end
end
if fields.quit or fields.cancel then
st.draft = nil; api.close_formspec(player:get_player_name(), C.FORM); return true
end
st.draft = draft
if fields.apply then
local raw = C.encode(draft)
local ok, result = false, false
if raw then ok, result = pcall(function() return player:get_meta():set_string(key, raw) end) end
if not ok or result == false then
st.warning = "Appearance could not be saved. Your previous appearance is unchanged; try again."
render(player, st); return true
end
st.saved, st.draft, st.warning = copy(draft), nil, nil
publish(player, draft)
api.close_formspec(player:get_player_name(), C.FORM); return true
elseif fields.reset then st.draft = C.defaults()
elseif fields.randomize then
for _, k in ipairs(order) do st.draft[k] = options[k][math.random(1, #options[k])] end
end
for i = 1, 4 do if fields["tab_" .. i] then st.tab = i; break end end
if fields.front then st.yaw = 180 elseif fields.profile then st.yaw = 90 elseif fields.back then st.yaw = 0 end
render(player, st); return true
end
api.register_on_player_receive_fields(function(player, form, fields)
if form ~= C.FORM then return end
C.handle(player, fields); return true
end)
api.register_chatcommand("character", {description = "Preview and save your woodland traveler appearance (cosmetic only)",
func = function(name)
local player = api.get_player_by_name(name)
if not player or not C.show(player) then return false, "Join the world first." end
return true
end})
return C
end
+63
View File
@@ -0,0 +1,63 @@
-- Loaded after content/interface; schema and the persistence instance come from init.
local C, M, P = core, hearth.model, hearth.persistence
local singleplayer = C.is_singleplayer()
local deployment = C.settings:get("hearth_environment")
if deployment == nil then deployment = singleplayer and "local" or "production" end
if deployment ~= "local" and deployment ~= "staging" and deployment ~= "production" then
error("Last Hearth: hearth_environment must be local, staging, or production.", 0)
end
if not singleplayer and deployment == "local" then
error("Last Hearth: dedicated servers cannot use the local environment.", 0)
end
local date_setting = C.settings:get("hearth_preview_date")
local weather_setting = C.settings:get("hearth_preview_weather") or "auto"
local function nonempty(value) return value ~= nil and value ~= "" end
if deployment == "production" and (nonempty(date_setting) or weather_setting ~= "auto"
or C.settings:get("hearth_launcher_mode") ~= nil or C.settings:get("hearth_smoke_token") ~= nil
or C.settings:get("hearth_m1_course") ~= nil or C.settings:get("hearth_m1_course_nonce") ~= nil
or C.settings:get("hearth_m1_course_snag") ~= nil or C.settings:get("hearth_course_token") ~= nil) then
error("Last Hearth: production rejects fixture and launcher test settings.", 0)
end
local preview_date
if nonempty(date_setting) then
preview_date = tonumber(date_setting)
if not preview_date or preview_date ~= preview_date or preview_date < 946684800
or preview_date > 4102444800 or preview_date % 1 ~= 0 then
error("Last Hearth: hearth_preview_date must be an integer UTC timestamp from 2000 through 2100.", 0)
end
end
if weather_setting ~= "auto" and weather_setting ~= "clear" and weather_setting ~= "rain" then
error("Last Hearth: hearth_preview_weather must be auto, clear, or rain.", 0)
end
local configured = M.location(C.settings:get("hearth_latitude"), C.settings:get("hearth_longitude"))
if (deployment == "production" or not singleplayer) and not configured then
error("Last Hearth: server/production requires explicit valid hearth_latitude and hearth_longitude settings.", 0)
end
-- Always validate stored data, even when settings take precedence. Corrupt saves
-- must fail startup, not disappear behind a configured location or neutral preview.
local stored = P.load_world("location")
local selected = configured or (singleplayer and stored)
hearth.has_location = selected ~= nil and selected ~= false
-- The existing location form and HUD disclose this unchosen neutral preview.
local location = selected or {latitude = 0, longitude = 0}
function hearth.climate_at(timestamp)
if type(timestamp) ~= "number" or timestamp ~= timestamp or timestamp < 0 then return nil end
return M.solar(math.floor(timestamp), location.latitude, location.longitude)
end
function hearth.set_location(lat, lon)
assert(singleplayer, "Only local worlds can select location in game")
local value = assert(M.location(lat, lon), "Invalid reference coordinates")
-- Publish only after a successful save; never silently replace a failed record.
P.save_world("location", value)
location, hearth.has_location = value, true
end
function hearth.clock_update()
local now = preview_date or os.time()
hearth.clock = M.solar(now, location.latitude, location.longitude)
hearth.weather = M.weather(now, location.latitude, location.longitude)
if weather_setting ~= "auto" then hearth.weather = weather_setting end
C.set_timeofday(hearth.clock.timeofday)
end
C.after(0, hearth.clock_update)
+59
View File
@@ -0,0 +1,59 @@
-- Pure server combat rules: no engine, clock, random, persistence or regeneration.
local M = {}
local weapons = {
["hearth:axe"] = {reach = 2.5, cadence = 1.1, stamina = 12, damage = 4, wear = 655, sound = "hearth_swing_axe"},
["hearth:knife"] = {reach = 1.8, cadence = 0.7, stamina = 8, damage = 3, wear = 437, sound = "hearth_swing_knife"},
["hearth:spear"] = {reach = 3.4, cadence = 1.2, stamina = 14, damage = 5, wear = 546, sound = "hearth_swing_spear"},
}
local function finite(n) return type(n) == "number" and n == n and math.abs(n) < math.huge end
local function position(p) return type(p) == "table" and finite(p.x) and finite(p.y) and finite(p.z) end
function M.distance(a, b)
return math.sqrt((a.x - b.x)^2 + (a.y - b.y)^2 + (a.z - b.z)^2)
end
function M.facing(origin, target, look, threshold)
if not position(origin) or not position(target) or not position(look) then return false end
local distance, length = M.distance(origin, target), math.sqrt(look.x^2 + look.y^2 + look.z^2)
if distance < 0.001 or length < 0.001 then return false end
return ((target.x - origin.x) * look.x + (target.y - origin.y) * look.y
+ (target.z - origin.z) * look.z) / (distance * length) + 1e-9 >= threshold
end
-- Recent displacement check, NOT a full teleport/flight anticheat.
function M.sample(previous, pos, now)
if not position(pos) or not finite(now) then return previous end
local blocked = previous and previous.blocked_until or 0
if previous then
local dt = now - previous.time
if dt < 0 then return previous end
if dt <= 0.5 and M.distance(previous.pos, pos) > 1.25 + 8 * dt then blocked = now + 0.5 end
end
return {pos = {x = pos.x, y = pos.y, z = pos.z}, time = now, blocked_until = blocked}
end
function M.valid_attack(c)
local s, state, attack = c.session, c.state, weapons[c.weapon]
if c.live ~= true or type(s) ~= "table" or type(state) ~= "table" or s.waiting
or not finite(c.hp) or c.hp <= 0 then return nil, "inactive" end
if not finite(c.now) or c.now < (s.combat_now or 0) then return nil, "clock" end
if not attack then return nil, "weapon" end
if c.now < (s.attack_ready_at or 0) or c.now < (s.spar_ready_at or 0) then return nil, "cadence" end
if not finite(state.stamina) or state.stamina < attack.stamina then return nil, "stamina" end
if c.allowed ~= true then return nil, "claim" end
if not position(c.origin) or not position(c.eye) or not position(c.target) then return nil, "position" end
if M.distance(c.eye, c.target) > attack.reach then return nil, "reach" end
if not M.facing(c.eye, c.target, c.look, 0.35) then return nil, "aim" end
if c.clear ~= true then return nil, "obstructed" end
local sample = s.combat_sample
if sample then
local dt = c.now - sample.time
if dt < 0 or c.now < (sample.blocked_until or 0) then return nil, "movement" end
if dt <= 0.5 and M.distance(sample.pos, c.origin) > 1.25 + 8 * dt then return nil, "movement" end
end
local result = {}; for k, v in pairs(attack) do result[k] = v end
return result
end
function M.valid_guard(c)
return c.live == true and c.session ~= nil and not c.session.waiting and c.state ~= nil
and finite(c.hp) and c.hp > 0 and finite(c.state.stamina) and c.state.stamina >= 8
and c.weapon == "hearth:spear" and c.controls ~= nil and c.controls.RMB == true
and M.facing(c.eye, c.target, c.look, 0.4)
end
return M
+228
View File
@@ -0,0 +1,228 @@
-- Construction helpers; dependencies are supplied by the content registrar.
return function(hearth, api)
local MAX_REACH = 6
local directions = {
{x = 1, y = 0, z = 0}, {x = -1, y = 0, z = 0},
{x = 0, y = 0, z = 1}, {x = 0, y = 0, z = -1},
}
local function near(player, pos)
if not player or not player:is_player() then return false end
local p = player:get_pos()
if not p then return false end
local dx, dy, dz = p.x - pos.x, p.y - pos.y, p.z - pos.z
return dx * dx + dy * dy + dz * dz <= MAX_REACH * MAX_REACH
end
local function allowed(pos, player, action)
if not near(player, pos) then return false end
local name = player:get_player_name()
local protected = hearth.external_protected or api.is_protected
if (protected and protected(pos, name)) or not hearth.claim_allowed(pos, name, action or "build") then
api.record_protection_violation(pos, name)
return false
end
return true
end
local removal_safe
local function can_dig(pos, player)
if not allowed(pos, player, "dig") then return false end
if removal_safe(pos, player) then return true end
if player and hearth.feedback then
hearth.feedback(player, "Keep the connected structure supported and within one homestead before removing this block.", "warning")
elseif player then hearth.notify(player, "Keep connected construction supported before removing this block.") end
return false
end
local function node_def(pos)
local node = api.get_node_or_nil(pos)
return node and api.registered_nodes[node.name], node
end
local function placement_pos(pointed)
if not pointed or pointed.type ~= "node" then return nil end
local under = node_def(pointed.under)
local above = node_def(pointed.above)
if not under or not above then return nil end
if under.buildable_to then return pointed.under end
if above.buildable_to then return pointed.above end
end
local function place(stack, player, pointed)
if not pointed or pointed.type ~= "node" then return stack end
local def = node_def(pointed.under)
-- item_place dispatches right-click callbacks before attempting placement.
if def and def.on_rightclick and player and not player:get_player_control().sneak then
local action = def.groups and (def.groups.hearth_storage or 0) > 0 and "storage" or "use"
if not allowed(pointed.under, player, action) then return stack end
if action == "storage" then
return def.on_rightclick(pointed.under, api.get_node(pointed.under), player, stack, pointed) or stack
end
return api.item_place(stack, player, pointed)
end
local pos = placement_pos(pointed)
if not pos or not allowed(pos, player) then return stack end
return api.item_place(stack, player, pointed)
end
local function is_building(name)
return name == "hearth:wall" or name == "hearth:roof" or name == "hearth:foundation"
or name == "hearth:frame" or name == "hearth:stairs" or name == "hearth:door"
or name == "hearth:door_open" or name == "hearth:window" or name == "hearth:vent"
end
local function solid(def)
return def and def.walkable ~= false and not def.buildable_to
and (not def.liquidtype or def.liquidtype == "none")
and not (def.groups and (def.groups.leaves or def.groups.hearth_non_support))
end
local function same_pos(a, b)
return a.x == b.x and a.y == b.y and a.z == b.z
end
local function household_owner(pos)
return hearth.claim_owner_at and hearth.claim_owner_at(pos) or nil
end
local function same_household(target, support)
local target_owner = household_owner(target)
local support_owner = household_owner(support)
return support_owner == nil or support_owner == target_owner
end
local function supported(pos, ignore)
-- Search existing construction only: at most two horizontal links and
-- sixteen blocks down. The horizontal budget never resets at a new block,
-- so sequential placement cannot extend a cantilever indefinitely.
local queue = {{pos = pos, span = 0, depth = 0}}
local seen, index, unloaded = {}, 1, false
while index <= #queue do
local entry = queue[index]
index = index + 1
local p = entry.pos
local key = p.x .. ":" .. p.y .. ":" .. p.z .. ":" .. entry.span
if not seen[key] then
seen[key] = true
local below = {x = p.x, y = p.y - 1, z = p.z}
local def, node
if ignore and same_pos(below, ignore) then
def, node = api.registered_nodes.air or {walkable = false, buildable_to = true}, {name = "air"}
else def, node = node_def(below) end
if not def or node.name == "ignore" then unloaded = true end
if solid(def) and node.name ~= "ignore" and same_household(pos, below) then
if not is_building(node.name) then return true end
if entry.depth < 15 then
queue[#queue + 1] = {pos = below, span = entry.span,
depth = entry.depth + 1}
end
end
if entry.span < 2 then
for _, d in ipairs(directions) do
local adjacent = {x = p.x + d.x, y = p.y, z = p.z + d.z}
local _, n
if ignore and same_pos(adjacent, ignore) then n = {name = "air"}
else _, n = node_def(adjacent) end
if n and is_building(n.name) and same_household(pos, adjacent) then
queue[#queue + 1] = {pos = adjacent, span = entry.span + 1,
depth = entry.depth}
end
end
end
end
end
return false, unloaded and "unloaded" or "support"
end
-- Refuse support removal rather than cascading destruction. Check the maximum
-- span/height a placement may legally depend on, and fail closed across claims.
removal_safe = function(pos, player)
local target = api.get_node_or_nil(pos)
if not target or target.name == "ignore" then return false end
local target_def = api.registered_nodes[target.name]
if not solid(target_def) then return true end
local name = player:get_player_name()
local removed_owner = household_owner(pos)
for y = 0, 16 do
for dx = -2, 2 do for dz = -2, 2 do
local candidate = {x = pos.x + dx, y = pos.y + y, z = pos.z + dz}
if not same_pos(candidate, pos) then
local node = api.get_node_or_nil(candidate)
if not node or node.name == "ignore" then return false end
if is_building(node.name) then
local dependent_owner = household_owner(candidate)
if dependent_owner and dependent_owner ~= removed_owner then return false end
if not hearth.claim_allowed(candidate, name, "dig") then return false end
local ok = supported(candidate, pos)
if not ok then return false end
end
end
end end
end
return true
end
-- pos is the resolved destination, not a pointed_thing. This read-only
-- query deliberately does not call allowed(): previews must never record a
-- violation, consume an ItemStack, play sound, or create node metadata.
local function preview(pos, player, item)
if type(pos) ~= "table" then return false, "invalid" end
for _, axis in ipairs({"x", "y", "z"}) do
local n = pos[axis]
if type(n) ~= "number" or n ~= n or math.abs(n) > 31000 or n % 1 ~= 0 then
return false, "invalid"
end
end
if not near(player, pos) then return false, "distance" end
local name = player:get_player_name()
if api.is_protected(pos, name) or not hearth.claim_allowed(pos, name) then
return false, "protected"
end
local def, node = node_def(pos)
if not def or node.name == "ignore" then return false, "unloaded" end
if not def.buildable_to then return false, "occupied" end
local itemname = type(item) == "string" and item
if not itemname and item and (type(item) == "table" or type(item) == "userdata")
and type(item.get_name) == "function" then itemname = item:get_name() end
if type(itemname) ~= "string" or not api.registered_nodes[itemname] then
return false, "item"
end
if is_building(itemname) then
local ok, reason = supported(pos)
if not ok then return false, reason end
end
return true, "ok"
end
local function hint(player, message)
-- Retain chat compatibility for existing callers/tests without feedback.
if hearth.feedback then hearth.feedback(player, message, "warning")
else hearth.notify(player, message) end
if hearth.feedback_sound then hearth.feedback_sound("hearth_denial", player, 0.2) end
end
local function place_building(stack, player, pointed)
if not pointed or pointed.type ~= "node" then return stack end
local under = node_def(pointed.under)
if under and under.on_rightclick and player and not player:get_player_control().sneak then
if not allowed(pointed.under, player) then return stack end
return api.item_place(stack, player, pointed)
end
local pos = placement_pos(pointed)
if not pos or not allowed(pos, player) then return stack end
if not supported(pos) then
hint(player, "Needs solid support within two horizontal blocks (maximum height 16).")
return stack
end
return api.item_place(stack, player, pointed)
end
local function owner(pos, player)
if player and player:is_player() then
api.get_meta(pos):set_string("owner", player:get_player_name())
end
end
return {near = near, allowed = allowed, can_dig = can_dig, place = place,
place_building = place_building, owner = owner, preview = preview,
removal_safe = removal_safe}
end
+613 -334
View File
File diff suppressed because it is too large Load Diff
+261
View File
@@ -0,0 +1,261 @@
-- Disposable M1 acceptance course. MAIN loads this before any player joins.
-- Launcher contract (not implemented here): fresh HEARTH_HOME/course world,
-- hearth_m1_course=true, hearth_m1_course_nonce=<new UUID>, and an exclusive
-- world/.hearth-m1-course-nonce file containing that UUID plus optional LF.
-- Use the summer fixture (UTC 1782043200, clear, latitude 45, longitude 0).
-- A setting alone NEVER authorizes editing an existing survival world.
local C, H = core, hearth
if H.course then return H.course end -- One command, generation and sampler.
local course = {}
H.course = course
local LOW, HIGH = {x = 0, y = 48, z = 0}, {x = 90, y = 58, z = 40}
local START = {x = 5, y = 50.5, z = 5}
local route = {
{15, 51, 5, "Half steps / walk without jumping"},
{26, 50.5, 5, "One-wide, two-high doorway"},
{39, 49.5, 10, "Pool / enter, swim, exit both ends"},
{50, 50.5, 7, "Two-node fall / compare landing"},
{62, 50.5, 7, "Four-node fall / compare landing"},
{71, 50.5, 10, "Logs and flint / axe and hand gathering"},
{79, 50.5, 20, "Construction / wall, roof, support preview"},
{82, 50.5, 33, "Safe arena / opt in with /practice or /spar"},
{60, 50.5, 33, "Target lane / measure reach, dodge and brace"},
{8, 50.5, 33, "Finish / /course report; /course retry"},
}
local kit = {"hearth:axe", "hearth:knife", "hearth:spear", "hearth:planks 64",
"hearth:wall 64", "hearth:roof 64", "hearth:berries 12", "hearth:water_clean 6"}
local runs, pending = {}, {}
local phase, generation_failed, generation = "idle", false, 0
local nonce, enrolled
local function finite(n) return type(n) == "number" and n == n and math.abs(n) < math.huge end
local function time() return C.get_us_time() / 1000000 end
local function feedback(player, message, kind)
if H.feedback then H.feedback(player, message, kind or "info") end
end
local function environment_ok(name)
local env = C.settings:get("hearth_environment")
if env == nil then env = C.is_singleplayer() and "local" or "production" end
if env == "production" then return false end -- Even server admins.
if env == "local" then return C.is_singleplayer() == true end
return env == "staging" and (name == nil or C.check_player_privs(name, {server = true}) == true)
end
local function uuid(value)
return type(value) == "string" and #value == 36
and value:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") ~= nil
end
local function marker_matches(value)
local world = C.get_worldpath():gsub("\\", "/"):gsub("/+$", "")
if world:match("([^/]+)$") ~= "course" then return false end
-- No user-controlled filename, deserialization, code loading or unbounded read.
local ok, result = pcall(function()
local f = io.open(world .. "/.hearth-m1-course-nonce", "rb")
if not f then return false end
local bytes = f:read(65); f:close()
return bytes == value or bytes == value .. "\n"
end)
return ok and result == true
end
local function configured()
return C.settings:get_bool("hearth_m1_course") == true and environment_ok(nil)
and uuid(nonce) and C.settings:get("hearth_m1_course_nonce") == nonce
end
-- Enroll ONLY on a fresh world's first mod load, before normal spawn/claims
-- migration. Never mint a marker from /course or infer one from empty claims.
nonce = C.settings:get("hearth_m1_course_nonce")
if configured() and marker_matches(nonce) and H.storage then
local storage = H.storage
local existing = storage:get_string("m1_course_world_nonce")
if existing == "" and not next(H.players) and not next(H.sessions) then
local fresh = true
for _, key in ipairs({"spawn", "claims", "location", "m0_backup:spawn",
"m0_backup:claims", "m0_backup:location"}) do
if storage:get_string(key) ~= "" then fresh = false end
end
if fresh then storage:set_string("m1_course_world_nonce", nonce) end
end
enrolled = storage:get_string("m1_course_world_nonce") == nonce
end
local function authorized(name)
-- Recheck settings and privileges after asynchronous emergence as well.
return enrolled == true and configured() and environment_ok(name)
and H.storage:get_string("m1_course_world_nonce") == nonce
end
local function live(player, session)
if not player or not player:is_player() then return false end
local name = player:get_player_name()
return C.get_player_by_name(name) == player and H.sessions[name] == session
and H.players[name] ~= nil and session ~= nil and not session.waiting
and player:get_hp() > 0
end
local function inside(p)
return p and finite(p.x) and finite(p.y) and finite(p.z)
and p.x >= LOW.x and p.x <= HIGH.x and p.y >= LOW.y and p.y <= HIGH.y
and p.z >= LOW.z and p.z <= HIGH.z
end
local function put(x, y, z, node)
local p = {x = x, y = y, z = z}
assert(inside(p), "Course write outside fixed bounds")
C.set_node(p, {name = node})
end
local slab = "hearth:m1_course_half_step"
if enrolled then
C.register_node(slab, {description = "Disposable course half step", drawtype = "nodebox",
tiles = {H.texture("#a48a61")}, paramtype = "light", walkable = true,
node_box = {type = "fixed", fixed = {-0.5, -0.5, -0.5, 0.5, 0, 0.5}},
groups = {choppy = 3, not_in_creative_inventory = 1}, drop = ""})
end
local function generate()
-- Full loaded preflight BEFORE the first write. The volume is fixed (41041
-- nodes); no terrain search, on_generated callback, or per-player generation.
for x = LOW.x, HIGH.x do for z = LOW.z, HIGH.z do for y = LOW.y, HIGH.y do
local n = C.get_node_or_nil({x = x, y = y, z = z})
if not n or n.name == "ignore" then return false end
end end end
for x = LOW.x, HIGH.x do for z = LOW.z, HIGH.z do for y = LOW.y, HIGH.y do
local edge = x == 0 or x == 90 or z == 0 or z == 40
put(x, y, z, (y <= 50 or (edge and y <= 53)) and "hearth:stone" or "air")
end end end
-- .5-node rises, full-node steps and a descending ramp with no autojump need.
for x = 11, 18 do for z = 4, 6 do
local height = math.min(x - 10, 19 - x)
for y = 51, 50 + math.ceil(height / 2) do
put(x, y, z, height % 2 == 1 and y == 50 + math.ceil(height / 2) and slab or "hearth:planks")
end
end end
-- Exactly one node wide, two clear nodes high; optional separate snag.
for z = 2, 8 do for y = 51, 53 do
if z ~= 5 or y == 53 then put(26, y, z, "hearth:wall") end
end end
if C.settings:get_bool("hearth_m1_course_snag") == true then
for x = 28, 30 do put(x, 52, 9, "hearth:roof") end
end
-- Contained two-deep pool, one-deep entry/exit shelves.
for x = 34, 44 do for z = 7, 13 do
for y = (x == 34 or x == 44) and 50 or 49, 50 do put(x, y, z, "hearth:water_source") end
end end
-- Approach ramps to fall platforms, then open landing pads at floor level.
for _, fall in ipairs({{46, 2}, {56, 4}}) do
for i = 0, fall[2] do for z = 6, 8 do
for y = 51, 50 + i do put(fall[1] + i, y, z, "hearth:planks") end
end end
end
for x = 68, 72, 2 do put(x, 51, 10, "hearth:log"); put(x, 51, 12, "hearth:loose_stone") end
-- Distinct construction anchor; arena/target lane remain clear and dry.
put(77, 51, 20, "hearth:wall")
for x = 8, 66, 2 do put(x, 50, 33, "hearth:planks") end
for x = 73, 87 do for z = 28, 38 do put(x, 50, z, "hearth:planks") end end
return true
end
local function append(buffer, value)
buffer.index = buffer.index % 600 + 1
buffer[buffer.index] = value
buffer.count = math.min(600, buffer.count + 1)
end
local function samples() return {index = 0, count = 0} end
local function percentile(buffer)
if buffer.count == 0 then return "n/a" end
local sorted = {}
for i = 1, buffer.count do sorted[i] = buffer[i] end
table.sort(sorted)
return string.format("%.2f", sorted[math.ceil(#sorted * 0.95)])
end
function course.report(name)
local run = runs[name]
if not run then return "No course run in this session. Use /course." end
return string.format("Course %s: %.1fs; checkpoints %d/%d. Engine globalstep dt p95 %sms (last %d samples); course Lua callback wall duration p95 %sus. Not client FPS or CPU profiling. Hits: not instrumented.",
run.finished and "complete" or "running", (run.finished or time()) - run.started,
run.checkpoint, #route, percentile(run.dt), run.dt.count, percentile(run.lua))
end
local function begin(player, session)
local name = player:get_player_name()
if not authorized(name) or not live(player, session) then return end
-- Intentionally replaces the kit/needs only in the enrolled disposable world.
player:get_inventory():set_list("main", kit)
for key, value in pairs({food = 100, water = 100, warmth = 75, wet = 0, stamina = 100}) do
H.players[name][key] = value
end
player:set_hp(20, {type = "set_hp", from = "mod"})
player:set_pos({x = START.x, y = START.y, z = START.z})
player:set_velocity({x = 0, y = 0, z = 0})
-- Keep session combat cooldowns intact; teleport sampling is handled by combat.
runs[name] = {player = player, session = session, started = time(), checkpoint = 0,
dt = samples(), lua = samples(), next_check = 0}
feedback(player, "Course started / east: half steps, doorway, pool; /course report", "success")
C.chat_send_player(name, "Disposable course kit. Walk east through steps/doorway/pool, 2/4-node falls, logs/flint, build pad, arena then west along target lane. Use /practice or /spar explicitly; no automatic opponent. /course report shows server dt, not FPS; /course retry resets kit/needs/timer. Optional snag is at (29,51,9).")
end
local function request(player, session)
local name = player:get_player_name()
if phase == "ready" then begin(player, session); return true, "Course started. /course report for telemetry." end
if phase == "failed" then return false, "Course generation failed; relaunch a fresh disposable course world." end
pending[name] = {player = player, session = session}
if phase == "loading" then return true, "Course emergence pending." end
phase, generation_failed = "loading", false
generation = generation + 1
local ticket = generation
C.emerge_area(LOW, HIGH, function(_, action, left)
if phase ~= "loading" or ticket ~= generation then return end
if action == (C.EMERGE_CANCELLED or 0) or action == (C.EMERGE_ERRORED or 1) then generation_failed = true end
if left ~= 0 then return end
-- An opt-in request must still be live and authorized before ANY writes.
local consent = false
for n, entry in pairs(pending) do
if authorized(n) and live(entry.player, entry.session) then consent = true end
end
if not consent then pending, phase = {}, "idle"; return end
local ok, built = false, false
if not generation_failed then ok, built = pcall(generate) end
phase = ok and built and "ready" or "failed"
local queued = pending; pending = {}
for n, entry in pairs(queued) do
if authorized(n) and live(entry.player, entry.session) then
if phase == "ready" then begin(entry.player, entry.session)
else C.chat_send_player(n, "Course generation failed; relaunch a fresh disposable course world.") end
end
end
end)
return true, "Preparing bounded disposable course; inventory changes only after emergence."
end
C.register_chatcommand("course", {
params = "[retry|report]", description = "Opt in to the disposable M1 course (never production)",
func = function(name, param)
if not authorized(name) then return false, "Course denied: launch a fresh marked local/staging course world; staging requires server privilege." end
local player, session = C.get_player_by_name(name), H.sessions[name]
if not live(player, session) then return false, "Join, finish arrival and be alive before opting in." end
if param == "report" then return true, course.report(name) end
if param ~= "" and param ~= "retry" then return false, "Use /course, /course retry or /course report." end
local count = 0
for _ in pairs(runs) do count = count + 1 end
for n in pairs(pending) do if not runs[n] then count = count + 1 end end
if not runs[name] and not pending[name] and count >= 16 then return false, "Course session limit reached (16)." end
return request(player, session)
end,
})
C.register_globalstep(function(dt)
if not finite(dt) or dt <= 0 then return end
for name, run in pairs(runs) do
if not authorized(name) or not live(run.player, run.session) then runs[name] = nil
elseif not run.finished then
local before = C.get_us_time()
local t = before / 1000000
append(run.dt, dt * 1000) -- Engine-provided step interval, NOT Lua CPU time.
if t - run.started > 1800 then runs[name] = nil
elseif t >= run.next_check then
run.next_check = t + 0.1 -- No catch-up loop or terrain work in globalstep.
local p, checkpoint = run.player:get_pos(), route[run.checkpoint + 1]
if inside(p) and (p.x-checkpoint[1])^2 + (p.y-checkpoint[2])^2 + (p.z-checkpoint[3])^2 <= 25 then
run.checkpoint = run.checkpoint + 1
feedback(run.player, "Checkpoint " .. run.checkpoint .. "/" .. #route .. " / " .. checkpoint[4], "success")
if run.checkpoint == #route then run.finished = t end
end
end
append(run.lua, math.max(0, C.get_us_time() - before))
end
end
end)
C.register_on_leaveplayer(function(player)
local name = player:get_player_name(); runs[name], pending[name] = nil, nil
end)
C.register_on_dieplayer(function(player)
local name = player:get_player_name(); runs[name], pending[name] = nil, nil
end)
return course
+230
View File
@@ -0,0 +1,230 @@
-- 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"},
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}}
local species, flora = {}, {}
local function kind(value)
if hearth.seasons then local k=hearth.seasons.kind(value); if k then return k end end
local name=type(value)=="table" and value.name or value
return type(name)=="string" and (name:gsub("^hearth:","")) or nil
end
for _, s in ipairs(D.species_order) do species[s]=true end
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]+)$")
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)$")
if species[wood] then return part end
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_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)
if kind(value)=="lichen" then return D.is_rock(support) or D.is_ground(support) end
return D.is_ground(support)
end
local soils={oak={grass=true,soil=true,forest_floor=true},
birch={grass=true,soil=true,forest_floor=true,podzol=true},
pine={podzol=true,alpine_turf=true}, willow={alluvium=true,peat=true},
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}}
function D.supports_species(s, support)
if not species[s] then return false end
local b=hearth.biomes
if b and b.supports_species then return b.supports_species(s,support)==true end
return soils[s][kind(support)]==true
end
function D.suitable(value, h, season)
local k=kind(value)
local light,moisture=h.light or 15,h.moisture or 0.45
local s=D.tree_info(k)
if s then
if not D.supports_species(s,h.ground) then return false end
if s=="pine" then return moisture<0.8 and light>=5 end
if s=="spruce" then return moisture>=0.4 and moisture<=0.85 and light>=4 end
if s=="beech" then return moisture>=0.3 and moisture<=0.7 and light>=4 end
if s=="willow" or s=="alder" then return moisture>=0.65 and light>=5 end
return moisture>=0.25 and moisture<0.85 and light>=5
end
if not D.supports_ground(k,h.ground) then return false end
if k=="lichen" then return light>=11 and moisture<0.6 end
if k=="clover" then return light>=10 and moisture>=0.3 and moisture<0.75 end
if k=="nettle" then return light>=6 and moisture>=0.5 and moisture<0.9 and (h.nutrients or 0)>0 end
if k=="heather" then return (h.ground=="podzol" or h.ground=="alpine_turf") and light>=9 and moisture<0.6 end
if k=="bluebell" then return light>=4 and light<=11 and moisture>=0.45 and moisture<0.85 end
if k=="sedge" or k=="bulrush" then return moisture>=0.75 and light>=7 end
if k=="moss" then return light<=9 and moisture>=0.6 end
return true
end
function D.edible(value, now)
local k=kind(value)
local available=not hearth.seasons or hearth.seasons.available(k,now)
return available and ({meadow_grass=true,fern=true,clover=true,sedge=true,nettle=true})[k]==true
end
function D.pollinator(value, season)
local k=kind(value)
return (season=="Spring" or season=="Summer") and (k=="clover" or k=="wildflowers"
or (k=="bluebell" and season=="Spring")) or (k=="heather" and season=="Autumn")
end
function D.choose_tree(p,h)
local choices
if h.ground=="podzol" or h.ground=="alpine_turf" then
choices=(h.ground=="podzol" and (h.heat or 20)>55) and {"birch","pine"} or {"pine","birch"}
elseif (h.moisture or 0)>=0.75 or h.ground=="alluvium" or h.ground=="peat" then
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
-- 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]
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
-- Read-only geometry handoff. Fresh offset records, root at (0,0,0), no air
-- writes. The ecology owner MUST preflight every destination/support before
-- applying a plan, track its own components and refuse foreign/player nodes.
-- "generic" uses canonical wood with the oak silhouette for legacy trees.
function D.tree_plan(s,stage)
stage=stage or "mature"
if (not species[s] and s~="generic") or (stage~="young" and stage~="mature") then return nil end
local b=hearth.biomes
if not b or not b.tree_schematic then return nil end
local schematic=b.tree_schematic(s=="generic" and "oak" or s,s=="generic",stage)
local plan={size={x=schematic.size.x,y=schematic.size.y,z=schematic.size.z}}
local cx,cz=math.floor(schematic.size.x/2),math.floor(schematic.size.z/2)
local root=s=="generic" and (stage=="young" and "young_tree" or "mature_tree") or s.."_"..stage
for z=0,schematic.size.z-1 do for y=0,schematic.size.y-1 do for x=0,schematic.size.x-1 do
local cell=schematic.data[1+x+schematic.size.x*(y+schematic.size.y*z)]
if cell.prob==255 and cell.name~="air" then
plan[#plan+1]={x=x-cx,y=y,z=z-cz,name=(x==cx and y==0 and z==cz) and "hearth:"..root or cell.name,
param2=cell.param2 or 0}
end
end end end
return plan
end
-- Deterministic niches give useful diversity on first active establishment;
-- not a random reroll, tree replacement, or a second mapgen/scanning pass.
function D.choose_recruit(p,h,season)
if not h.loaded or season=="Winter" then return nil end
local hash=math.abs(p.x*3+p.z*5)%8
local candidates
if D.rocks[h.ground] then candidates={"lichen"}
elseif h.moisture>=0.75 then candidates={hash%2==0 and "sedge" or "bulrush","moss"}
elseif h.light<=9 then candidates={season=="Spring" and "bluebell" or "moss","moss"}
elseif h.ground=="podzol" or h.ground=="alpine_turf" then candidates={"heather","lichen"}
else candidates={({[1]="clover",[2]="nettle",[3]="bluebell",[4]="clover"})[hash]} end
for _,candidate in ipairs(candidates) do
if D.suitable(candidate,h,season) and (candidate~="bluebell" or season=="Spring") then return candidate end
end
end
local function unmarked(p)
local m=api.get_meta(p)
if m.to_table then for field,value in pairs((m:to_table() or {}).fields or {}) do
if field:sub(1,5)~="succ_" and value~="" and tostring(value)~="0" then return false end
end end
for _,field in ipairs({"owner","planter","placed_by","player_placed","hearth_placed","hearth_player_placed","planted_at"}) do
local v=m:get_string(field); if v~="" and v~="0" then return false end
end
if hearth.claim_owner_at then local owner,ambiguous=hearth.claim_owner_at(p); if owner or ambiguous then return false end end
local protect=hearth.external_protected or api.is_protected
return not (protect and protect(p,""))
end
function D.bootstrap(p,n)
if type(p)~="table" then return false end
for _,axis in ipairs({"x","y","z"}) do
local v=p[axis]
if type(v)~="number" or v~=v or math.abs(v)>30990 then return false end
end
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 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
local m=api.get_meta(p)
if m:get_string("succ_natural")=="1" then return false end
m:set_string("succ_natural","1")
return true
end
local seasons=hearth.seasons
if seasons and seasons.add_variants then
for _,s in ipairs(D.species_order) do
local evergreen=s=="pine" or s=="spruce"
for _,stage in ipairs({"seedling","young","mature","leaves"}) do
local definitions={}
if not evergreen then for _,season in ipairs({"Spring","Autumn","Winter"}) do
definitions[season]={texture=stage=="leaves" and ("hearth_"..s.."_leaves_"..season:lower()..".png")
or (stage=="seedling" and ("hearth_botany_"..s..".png") or ("hearth_"..s.."_log_top.png"))}
end end
seasons.add_variants(s.."_"..stage,definitions,evergreen and {Spring=true,Summer=true,Autumn=true,Winter=true}
or {Spring=true,Summer=true,Autumn=true})
end
end
for _,plant in ipairs(D.plants) do
local definitions,active={},nil
if plant~="moss" and plant~="lichen" then
definitions.Winter={texture="hearth_"..plant.."_dormant.png"}
if plant=="bluebell" then
definitions.Summer={texture="hearth_bluebell_dormant.png"}
definitions.Autumn={texture="hearth_bluebell_dormant.png"}; active={Spring=true}
end
end
seasons.add_variants(plant,definitions,active)
end
end
local registered=false
function D.register_nodes(register_node)
if registered then return end
registered=true
local function placed(p) api.get_meta(p):set_string("player_placed","1") end
for _,s in ipairs(D.species_order) do for _,stage in ipairs({"seedling","young","mature"}) do
local woody=stage~="seedling"
local def={description=s:gsub("^%l",string.upper).." "..stage.." tree",
drawtype=woody and "normal" or "plantlike",
tiles=woody and {"hearth_"..s.."_log_top.png","hearth_"..s.."_log_top.png","hearth_"..s.."_log_side.png"}
or {"hearth_botany_"..s..".png"},
paramtype=not woody and "light" or nil,paramtype2=woody and "facedir" or nil,
sunlight_propagates=not woody,use_texture_alpha=not woody and "clip" or nil,walkable=woody,buildable_to=false,
groups=woody and {tree=1,choppy=2,flammable=2,hearth_ecosystem=1,not_in_creative_inventory=1}
or {snappy=3,flora=1,flammable=2,hearth_ecosystem=1},
drop=woody and "hearth:"..s.."_log" or "",after_place_node=placed}
register_node(s.."_"..stage,def)
-- Preserve existing seasonal stage IDs in saves. Register bark cubes
-- here so the foliage-only seasonal copier never flattens their three
-- face textures into one atlas/tile. Seasons still owns name selection.
if seasons and woody and s~="pine" and s~="spruce" then
for _,season in ipairs({"spring","autumn","winter"}) do
local variant={}; for k,v in pairs(def) do variant[k]=v end
register_node(s.."_"..stage.."_"..season,variant)
end
end
end end
for _,plant in ipairs(D.plants) do
register_node(plant,{description=plant:gsub("^%l",string.upper),drawtype="plantlike",tiles={"hearth_"..plant..".png"},
paramtype="light",sunlight_propagates=true,use_texture_alpha="clip",walkable=false,buildable_to=false,
groups={snappy=3,flora=1,flammable=2,hearth_ecosystem=1},drop="",after_place_node=placed})
end
local names={"hearth:deadwood"}
for k in pairs(flora) do names[#names+1]="hearth:"..k 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",
nodenames=names,run_at_every_load=true,action=function(p,n) D.bootstrap(p,n) end})
end
return D
end
+230
View File
@@ -0,0 +1,230 @@
-- 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 function finite(value)
value = tonumber(value)
if not value or value ~= value or value == math.huge or value == -math.huge then return nil end
return value
end
local function safe_now()
local now = os.time()
if type(now) ~= "number" or now ~= now or now < 0 or now == math.huge or now == -math.huge then return 0 end
return math.floor(now)
end
local function node(pos)
local n = api.get_node_or_nil(pos)
if not n or n.name == "ignore" then return nil end
return n
end
local function free_crop(pos, player)
local below, here = node({x=pos.x,y=pos.y-1,z=pos.z}), node(pos)
local here_def = here and api.registered_nodes[here.name]
if not below or not here_def or not here_def.buildable_to then return false end
if below.name ~= "hearth:grass" and below.name ~= "hearth:soil" then return false end
return construction.allowed(pos, player, "build")
end
local function climate_progress(planted, now)
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)
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
end
return math.min(CROP_MATURE_HOURS, progress)
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
return 1
end
local function update_crop(pos)
local current = node(pos)
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
local stage = crop_stage(planted, now)
local target = "hearth:crop_" .. stage
if current.name ~= target then api.swap_node(pos, {name = target, param2 = current.param2}) end
return stage < 4
end
local function reconcile_crop(pos)
if update_crop(pos) then api.get_node_timer(pos):start(CROP_STEP) end
end
local function plant_crop(stack, player, pointed)
if not pointed or pointed.type ~= "node" or not player or not player:is_player() then return stack end
local pos = pointed.above
if not free_crop(pos, player) then return stack end
api.set_node(pos, {name = "hearth:crop_1"})
api.get_meta(pos):set_string("planted_at", tostring(safe_now()))
api.get_meta(pos):set_string("planter", player:get_player_name())
api.get_node_timer(pos):start(CROP_STEP)
stack:take_item(1)
return stack
end
local function harvest_crop(pos, player)
if not construction.allowed(pos, player, "use") then return end
local n = node(pos)
if not n or n.name ~= "hearth:crop_4" then
hearth.notify(player, "The crop is still growing; seasons and temperature affect its progress.")
return
end
local output = ItemStack("hearth:grain 4")
local inv = player:get_inventory()
if not inv or not inv:room_for_item("main", output) then
hearth.notify(player, "Make room for grain before harvesting.")
return
end
local requested = output:get_count()
local leftover = inv:add_item("main", output)
if not leftover or leftover:get_count() >= requested then
hearth.notify(player, "Harvest stopped; make inventory space and try again.")
return
end
api.set_node(pos, {name = "hearth:soil"})
-- Harvested farmland is player-created terrain, not a fresh native meadow.
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
local n = node(pos)
if not n then return end
if n.name == "hearth:berry_bush_bare" then
hearth.notify(player, "The berry bush is regrowing; return after one real day.")
return
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
end
local requested = output:get_count()
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)")
api.get_node_timer(pos):start(DAY)
if hearth.invalidate_environment then hearth.invalidate_environment(pos) end
end
local function harvest_seasonal(pos, player)
if not construction.allowed(pos, player, "use") then return false end
local n = node(pos)
local seasons = hearth.seasons
local kind = n and seasons and seasons.kind(n)
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
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))
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
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()
return false
end
return true
end
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())))
end
end
local function reconcile_bush(pos)
if refresh_bush(pos) then
local ready = tonumber(api.get_meta(pos):get_string("ready_at")) or safe_now() + DAY
api.get_node_timer(pos):start(math.max(1, math.min(DAY, ready - safe_now())))
end
end
local function tree_clear(pos, owner)
local limit = tonumber(api.settings and api.settings:get("mapgen_limit")) or 512
if math.abs(pos.x) > limit - 4 or math.abs(pos.z) > limit - 4 or pos.y < -31000 or pos.y > limit - 6 then return false end
for y = 0, 5 do for x = -2, 2 do for z = -2, 2 do
local p = {x=pos.x+x,y=pos.y+y,z=pos.z+z}
local n = node(p)
if not n then return false end
local kind = hearth.seasons and hearth.seasons.kind(n) or n.name:match("^hearth:(.+)$")
if not (x == 0 and y == 0 and z == 0) and n.name ~= "air" and kind ~= "leaves" then return false end
if not hearth.claim_allowed(p, owner or "", "build")
or (hearth.external_protected and hearth.external_protected(p, owner or "")) then return false end
end end end
return true
end
grow_sapling = function(pos)
local n = node(pos)
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
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
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
end
end end end
return false
end
local function plant_sapling(stack, player, pointed)
if not pointed or pointed.type ~= "node" or not player or not player:is_player() then return stack end
local pos = pointed.above
if not free_crop(pos, player) then return stack end
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)
stack:take_item(1)
return stack
end
local function berry_dig(pos, node, digger)
if digger and digger:is_player() then harvest_berries(pos, digger) end
end
local function crop_dig(pos, node, digger)
if digger and digger:is_player() then harvest_crop(pos, digger) end
end
return {plant_crop = plant_crop, update_crop = update_crop, crop_stage = crop_stage,
climate_progress = climate_progress,
harvest_crop = harvest_crop, crop_dig = crop_dig, harvest_berries = harvest_berries,
berry_dig = berry_dig,
harvest_seasonal = harvest_seasonal,
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}
end
+263 -68
View File
@@ -1,75 +1,270 @@
local C = core
-- A deliberately artificial sparring target: opt-in, persistent, non-lethal.
-- No claim of production combat animation/prediction: validates the first loop.
C.register_entity("hearth:practice", {
initial_properties = {physical = true, collide_with_objects = false,
collisionbox = {-0.3, 0, -0.3, 0.3, 1.6, 0.3}, visual = "cube",
visual_size = {x = 0.6, y = 1.6, z = 0.6},
textures = {hearth.texture("#c7aa83"), hearth.texture("#947853"), hearth.texture("#947853"),
hearth.texture("#947853"), hearth.texture("#947853"), hearth.texture("#947853")},
pointable = true, hp_max = 100, static_save = true,
nametag = "PRACTICE / strike to spar · step back to stop", nametag_color = "#e5c890"},
on_activate = function(self)
self.object:set_armor_groups({immortal = 1})
self.object:set_acceleration({x = 0, y = -9.81, z = 0})
self.health, self.cooldowns = 30, {}
end,
on_punch = function(self, puncher)
if not puncher or not puncher:is_player() then return true end
local pos, p = self.object:get_pos(), puncher:get_pos()
if not pos or not p or vector.distance(pos, p) > 3.5 then return true end
if not C.line_of_sight(vector.offset(p, 0, 1.2, 0), vector.offset(pos, 0, 0.8, 0), 0.25) then return true end
local name = puncher:get_player_name()
local state = hearth.players[name]
if not state then return true end
local tool = puncher:get_wielded_item():get_name()
local attacks = {['hearth:spear'] = {5, 1.2}, ['hearth:axe'] = {4, 1.1}, ['hearth:knife'] = {3, 0.7}}
local attack = attacks[tool] or {1, 1}
local now = C.get_us_time() / 1000000
if now - (self.cooldowns[name] or -100) < attack[2] or state.stamina < 10 then return true end
self.cooldowns[name] = now
state.stamina = state.stamina - 10
self.health = self.health - attack[1]
-- A faster weapon cannot postpone an already committed counter forever.
if not self.target then self.target, self.delay = name, 0.75 end
self.object:set_properties({nametag = "COUNTER INCOMING / brace or step away", nametag_color = "#f38ba8"})
if self.health <= 0 then
self.health, self.target = 30, nil
self.object:set_properties({nametag = "ROUND COMPLETE / ready again", nametag_color = "#a6e3a1"})
hearth.notify(puncher, "Practice complete. Good footwork saves stamina; right-click your spear to brace.")
else hearth.notify(puncher, "Hit confirmed / target " .. self.health .. "/30") end
return true
end,
on_step = function(self, dt)
if not self.target then return end
self.delay = self.delay - math.min(dt, 0.1)
if self.delay > 0 then return end
local player = C.get_player_by_name(self.target)
if player and player:get_hp() > 2 then
local p, pos = player:get_pos(), self.object:get_pos()
if vector.distance(p, pos) < 2.5 and C.line_of_sight(vector.offset(pos, 0, 1, 0), vector.offset(p, 0, 1, 0), 0.25) then
local state, session = hearth.players[self.target], hearth.sessions[self.target]
local blocking = state and session and session.blocking and state.stamina >= 8
if blocking then state.stamina = state.stamina - 8 end
player:set_hp(math.max(2, player:get_hp() - (blocking and 1 or 3)), {type = "set_hp", from = "mod"})
hearth.notify(player, blocking and "Braced / reduced impact" or "Counter landed / step back beyond reach")
-- Opt-in, non-lethal training only. No wildlife, drops, node edits or PvP.
local C, M = core, assert(hearth.combat, "Install hearth.combat before encounters")
local moving = {} -- One ephemeral opponent per consenting player; never serialized.
local function now() return C.get_us_time() / 1000000 end
local function offset(p, y) return {x = p.x, y = p.y + y, z = p.z} end
local function copy(p) return {x = p.x, y = p.y, z = p.z} end
local function feedback(player, message, kind)
if hearth.feedback then hearth.feedback(player, message, kind) end
end
local function sound(name, player)
if hearth.feedback_sound then hearth.feedback_sound(name, player, 0.25)
else C.sound_play(name, {pos = player:get_pos(), gain = 0.25, max_hear_distance = 12}, true) end
end
local function live(player)
if not player or not player:is_player() then return false end
local name = player:get_player_name()
return C.get_player_by_name(name) == player and hearth.players[name] ~= nil
and hearth.sessions[name] ~= nil and not hearth.sessions[name].waiting and player:get_hp() > 0
end
local function allowed(pos, name)
return pos ~= nil and hearth.claim_allowed(pos, name, "combat") == true and not C.is_protected(pos, name)
end
local function eye(player)
return offset(player:get_pos(), player:get_properties().eye_height or 1.55)
end
local function stop(self)
local v = self.object:get_velocity()
self.object:set_velocity({x = 0, y = v.y, z = 0})
end
local animations = {idle = {0, 19, 12}, approach = {20, 39, 24}, windup = {70, 79, 12},
strike = {60, 69, 28}, recover = {0, 19, 12}, cooldown = {0, 19, 12}}
local function phase(self, value, t, duration)
self.phase, self.deadline = value, t + (duration or 0)
if value == "approach" then self.approach_until = t + 8 end
if self.mobile then
local f = animations[value]
self.object:set_animation({x = f[1], y = f[2]}, f[3], 0.12, value ~= "strike")
end
local label = value == "windup" and "COUNTER INCOMING / step aside or face + brace"
or (value == "cooldown" and "ROUND COMPLETE / rest 3 seconds" or "PRACTICE / " .. self.health .. "/30")
self.object:set_properties({nametag = label})
end
local function finish(self, t, player, detached)
if self.phase == "cooldown" then return end
local s = self.target and hearth.sessions[self.target]
if s then
s.spar_ready_at = math.max(s.spar_ready_at or 0, t + 3)
if s.spar_entity == self then s.spar_entity = nil end
end
self.target, self.round_complete, self.direction = nil, false, nil
if not detached then stop(self); phase(self, "cooldown", t, 3) end
if player then feedback(player, "Round complete / rest, then strike to opt in again", "success") end
end
local function begin(self, player, t)
if not live(player) then return false end
local name, s = player:get_player_name(), hearth.sessions[player:get_player_name()]
if t < (s.spar_ready_at or 0) or (s.spar_entity and s.spar_entity ~= self) then return false end
self.target, s.spar_entity = name, self
self.stuck, self.perception, self.last_ai_pos, self.direction = 0, 0, nil, nil
if self.mobile then phase(self, "approach", t) else phase(self, "windup", t, 0.75) end
return true
end
local function punch(self, player)
-- Never trust client damage, elapsed punch time, direction or tool capabilities.
if not live(player) or self.phase == "cooldown" then return true end
local name, t = player:get_player_name(), now()
if (self.mobile and self.owner ~= name) or (self.target and self.target ~= name) then return true end
local state, s, pos = hearth.players[name], hearth.sessions[name], self.object:get_pos()
if not pos or (s.spar_entity and s.spar_entity ~= self) then return true end
local head, center, stack = eye(player), offset(pos, 0.9), player:get_wielded_item()
local attack = M.valid_attack({live = true, hp = player:get_hp(), state = state, session = s,
now = t, weapon = stack:get_name(), origin = player:get_pos(), eye = head, target = center,
look = player:get_look_dir(), allowed = allowed(pos, name) and allowed(player:get_pos(), name),
clear = M.distance(head, center) <= 3.4 and C.line_of_sight(head, center, 0.1) == true})
if not attack or self.round_complete then return true end
if not self.target and not begin(self, player, t) then return true end
state.stamina = state.stamina - attack.stamina
s.combat_now, s.attack_ready_at, s.strike_until = t, t + attack.cadence, t + 10 / 28
stack:add_wear(attack.wear); player:set_wielded_item(stack)
self.health = math.max(0, self.health - attack.damage)
sound(attack.sound, player); sound("hearth_impact", player)
feedback(player, "Hit confirmed / target " .. self.health .. "/30", "success")
-- Damage NEVER cancels or restarts a committed windup, even the final hit.
if self.health == 0 then self.round_complete = true end
return true
end
local function strike(self, player)
if not live(player) then return end
local pos, p, name = self.object:get_pos(), player:get_pos(), player:get_player_name()
if not pos or not allowed(pos, name) or not allowed(p, name) then return end
local center, head = offset(pos, 0.9), eye(player)
if M.distance(center, head) > 2.2 or not C.line_of_sight(center, head, 0.1) then
feedback(player, "Counter evaded", "info"); return
end
local state, s = hearth.players[name], hearth.sessions[name]
local guard = M.valid_guard({live = true, hp = player:get_hp(), state = state, session = s,
weapon = player:get_wielded_item():get_name(), controls = player:get_player_control(),
eye = head, target = center, look = player:get_look_dir()})
if guard then state.stamina = state.stamina - 8 end
if player:get_hp() > 2 then player:set_hp(math.max(2, player:get_hp() - (guard and 1 or 3)), {type = "set_hp", from = "mod"}) end
sound(guard and "hearth_block" or "hearth_impact", player)
feedback(player, guard and "Braced / reduced impact" or "Counter landed / step back or aside", guard and "success" or "warning")
end
local function node_ok(pos, ground)
local node = C.get_node_or_nil(pos)
local def = node and C.registered_nodes[node.name]
if not def or node.name == "ignore" or (def.liquidtype and def.liquidtype ~= "none")
or ((def.groups or {}).water or 0) > 0 or ((def.groups or {}).liquid or 0) > 0 then return false end
return ground and def.walkable == true or (not ground and def.walkable == false)
end
local function safe_step(self, pos, dx, dz)
local nextpos = {x = pos.x + dx * 0.7, y = pos.y, z = pos.z + dz * 0.7}
return M.distance(nextpos, self.home) <= 12 and allowed(nextpos, self.target)
and node_ok(offset(nextpos, -0.2), true) and node_ok(offset(nextpos, 0.1), false)
and node_ok(offset(nextpos, 1.3), false)
and C.line_of_sight(offset(pos, 0.9), offset(nextpos, 0.9), 0.1) == true
end
local function perceive(self, pos, destination, dt)
local dx, dz = destination.x - pos.x, destination.z - pos.z
local length = math.sqrt(dx * dx + dz * dz)
self.direction = nil
if length < 0.1 then return end
dx, dz = dx / length, dz / length
if self.last_ai_pos and M.distance(self.last_ai_pos, pos) < 0.08 then self.stuck = self.stuck + dt
else self.stuck = 0 end
self.last_ai_pos = copy(pos)
if self.stuck > 3 then return end
if self.stuck > 1.5 then
dx, dz = self.home.x - pos.x, self.home.z - pos.z
length = math.sqrt(dx * dx + dz * dz)
if length < 0.1 then return end
dx, dz = dx / length, dz / length
end
for _, d in ipairs({{dx, dz}, {-dz, dx}, {dz, -dx}}) do
if safe_step(self, pos, d[1], d[2]) then self.direction = d; return end
end
end
local function step(self, dt)
dt = math.max(0, math.min(tonumber(dt) or 0, 0.1))
local t, pos = now(), self.object:get_pos()
if not pos then return end
if self.phase == "cooldown" then
stop(self)
if t >= self.deadline then self.health = 30; phase(self, "idle", t) end
return
end
if not self.target then stop(self); return end
local player = C.get_player_by_name(self.target)
if not live(player) or not allowed(pos, self.target) or not allowed(player:get_pos(), self.target)
or M.distance(player:get_pos(), self.home) > 12 or M.distance(pos, self.home) > 12 then
finish(self, t); return
end
if self.phase == "windup" then
stop(self)
if t >= self.deadline then phase(self, "strike", t, 0.12); strike(self, player) end
elseif self.phase == "strike" then
stop(self); if t >= self.deadline then phase(self, "recover", t, 0.9) end
elseif self.phase == "recover" then
stop(self)
if t >= self.deadline then
if self.round_complete then finish(self, t, player)
elseif self.mobile then phase(self, "approach", t)
else
local s = hearth.sessions[self.target]; if s and s.spar_entity == self then s.spar_entity = nil end
self.target = nil; phase(self, "idle", t)
end
end
self.target = nil
self.object:set_properties({nametag = "PRACTICE / " .. self.health .. "/30 · strike to spar", nametag_color = "#e5c890"})
end,
})
elseif self.phase == "approach" then
-- A moving-but-unproductive avoidance loop also has a hard budget.
if self.round_complete or t >= self.approach_until then finish(self, t, player); return end
self.perception = self.perception + dt
if self.perception >= 0.2 then
self.perception = 0
local p = player:get_pos()
self.object:set_yaw(C.dir_to_yaw({x = p.x - pos.x, y = 0, z = p.z - pos.z}))
if M.distance(offset(pos, 0.9), eye(player)) <= 2.0
and C.line_of_sight(offset(pos, 0.9), eye(player), 0.1) then
stop(self); phase(self, "windup", t, 0.75)
feedback(player, "Counter incoming / step aside or face + brace", "warning"); return
end
perceive(self, pos, player:get_pos(), 0.2)
if self.stuck > 3 then finish(self, t, player); return end
end
local d, v = self.direction, self.object:get_velocity()
self.object:set_velocity({x = d and d[1] * 2.2 or 0, y = v.y, z = d and d[2] * 2.2 or 0})
end
end
local function definition(mobile)
return {
initial_properties = {physical = true, collide_with_objects = false,
collisionbox = {-0.3, 0, -0.3, 0.3, 1.6, 0.3}, visual = mobile and "mesh" or "cube",
mesh = "hearth_person.b3d", textures = mobile and {"hearth_person.png"}
or {hearth.texture("#c7aa83"), hearth.texture("#947853"), hearth.texture("#947853"),
hearth.texture("#947853"), hearth.texture("#947853"), hearth.texture("#947853")},
visual_size = mobile and {x = 1, y = 1} or {x = 0.6, y = 1.6, z = 0.6},
pointable = true, hp_max = 100, static_save = not mobile,
nametag = "PRACTICE / strike to opt in", nametag_color = "#e5c890"},
on_activate = function(self)
self.mobile, self.health, self.perception, self.stuck = mobile, 30, 0, 0
self.target, self.owner, self.round_complete, self.direction = nil, nil, false, nil
self.last_ai_pos = nil
self.home = copy(self.object:get_pos())
self.object:set_armor_groups({immortal = 1})
self.object:set_acceleration({x = 0, y = -9.81, z = 0})
stop(self); phase(self, "idle", now()) -- Ignore old staticdata; keep identity, reset health.
end,
on_punch = punch, on_step = step,
on_deactivate = function(self)
if self.owner and moving[self.owner] == self then moving[self.owner] = nil end
if self.target then finish(self, now(), nil, true) end
end,
}
end
C.register_entity("hearth:practice", definition(false))
C.register_entity("hearth:sparring", definition(true))
C.register_chatcommand("practice", {description = "Place one practice opponent near you (local play only)",
func = function(name)
if not C.is_singleplayer() then return false, "Practice placement is local-play only." end
local player = C.get_player_by_name(name)
if not player then return false end
local pos = vector.round(vector.add(player:get_pos(), vector.multiply(player:get_look_dir(), 3)))
-- Independent movement observations, including before the first hit.
local sample_elapsed = 0
C.register_globalstep(function(dt)
sample_elapsed = sample_elapsed + math.max(0, math.min(dt, 0.1))
if sample_elapsed < 0.1 then return end
sample_elapsed = 0
local t = now()
for _, player in ipairs(C.get_connected_players()) do
if live(player) then
local s = hearth.sessions[player:get_player_name()]
s.combat_sample = M.sample(s.combat_sample, player:get_pos(), t)
end
end
end)
local function deployment_allowed(name, practice)
local single = C.is_singleplayer()
local deployment = C.settings:get("hearth_environment") or (single and "local" or "production")
if deployment == "production" then return false end -- No developer spawning, even for admins.
if practice then return single and deployment == "local" end
return (single and deployment == "local")
or (deployment == "staging" and C.check_player_privs(name, {server = true}) == true)
end
local function spawn(name, mobile)
if not deployment_allowed(name, not mobile) then return false, "Training placement requires local play or privileged staging /spar." end
local player, t = C.get_player_by_name(name), now()
if not live(player) then return false, "Join and finish arrival first." end
local s = hearth.sessions[name]
if t < (s.spar_ready_at or 0) or s.spar_entity then return false, "Finish your round and rest first." end
if mobile and moving[name] and moving[name].object:get_pos() then return false, "Your sparring opponent is already placed; strike it to opt in again." end
local p, look = player:get_pos(), player:get_look_dir()
local length = math.sqrt(look.x^2 + look.z^2)
if length < 0.01 then return false, "Look towards open ground." end
local pos = {x = p.x + look.x / length * 3, y = p.y, z = p.z + look.z / length * 3}
if not allowed(p, name) or not allowed(pos, name) or not node_ok(offset(pos, -0.2), true)
or not node_ok(offset(pos, 0.1), false) or not node_ok(offset(pos, 1.3), false)
or not C.line_of_sight(eye(player), offset(pos, 0.9), 0.1) then return false, "Look at clear, loaded, dry ground outside other claims." end
if not mobile then
for _, obj in ipairs(C.get_objects_inside_radius(pos, 30)) do
local e = obj:get_luaentity()
if e and e.name == "hearth:practice" then return false, "A practice target is already nearby." end
end
if C.get_node(pos).name ~= "air" or C.is_protected(pos, name) then return false, "Look at an open space nearby." end
return C.add_entity(pos, "hearth:practice") ~= nil, "Practice target placed. Strike it to start; step away to stop."
end})
end
local object = C.add_entity(pos, mobile and "hearth:sparring" or "hearth:practice")
if not object then return false, "Could not place training target." end
if mobile then
local e = object:get_luaentity(); e.owner, moving[name] = name, e
begin(e, player, t)
end
return true, mobile and "Sparring started / step back or aside; face with spear + RMB to brace." or "Practice target placed. Strike it to start."
end
C.register_chatcommand("practice", {description = "Place a stationary practice target (local play only)",
func = function(name) return spawn(name, false) end})
C.register_chatcommand("spar", {description = "Opt in to one moving sparring opponent (local or server-privileged staging only)",
func = function(name) return spawn(name, true) end})
+139
View File
@@ -0,0 +1,139 @@
-- Bounded room flood-fill. Revisions invalidate cached geometry on building edits.
local C, M = core, hearth.model
local ROOM_RADIUS, ROOM_BELOW, ROOM_ABOVE = 3, 1, 3
local MAX_CELLS = (ROOM_RADIUS * 2 + 1) ^ 2 * (ROOM_BELOW + ROOM_ABOVE + 1)
local CACHE_SECONDS, CACHE_LIMIT = 1, 256
local cache, cache_count, revision = {}, 0, 0
local function key(pos)
return math.floor(pos.x + 0.5) .. ":" .. math.floor(pos.y + 0.5) .. ":" .. math.floor(pos.z + 0.5)
end
local function read(pos)
local node = C.get_node_or_nil(pos)
if not node or node.name == "ignore" then return nil, nil, true end
local def = C.registered_nodes[node.name]
if not def then return node, nil, false end
return node, def, false
end
local function room_geometry(pos)
local head = vector.round({x = pos.x, y = pos.y + 1, z = pos.z})
local cache_key = key(head)
local now = C.get_us_time() / 1000000
local old = cache[cache_key]
if old and old.revision == revision and now - old.at < CACHE_SECONDS then
return old.geometry
end
local minx, maxx = head.x - ROOM_RADIUS, head.x + ROOM_RADIUS
local minz, maxz = head.z - ROOM_RADIUS, head.z + ROOM_RADIUS
local miny, maxy = head.y - ROOM_BELOW, head.y + ROOM_ABOVE
local first_node, first_def, unloaded = read(head)
local geometry = {roof = false, shelter = 0, enclosed = false, ventilated = false,
open_sides = 4, unloaded = unloaded}
local function blocks_room(node, def)
if not def then return true end -- Unknown nodes fail closed as room boundaries.
if node.name == "hearth:door_open" then return false end
return def.walkable ~= false
end
local function has_vent_near(p)
for _, d in ipairs({{1,0,0},{-1,0,0},{0,1,0},{0,-1,0},{0,0,1},{0,0,-1}}) do
local node, def, missing = read({x = p.x + d[1], y = p.y + d[2], z = p.z + d[3]})
unloaded = unloaded or missing
if node and def and def.groups and (def.groups.hearth_vent or 0) > 0 then return true end
end
return false
end
-- Flood only passable nodes inside a fixed 7 x 7 x 5 volume. Reaching a
-- horizontal boundary means that side admits weather; an upward/downward
-- escape means the room lacks a ceiling/floor. This never searches map-scale.
local queue, seen, index = {}, {}, 1
if first_node and first_def and not blocks_room(first_node, first_def) then
queue[1] = head
else
geometry.unloaded = unloaded
local roof = false
for y = 1, 5 do
local _, def, missing = read({x = head.x, y = head.y + y, z = head.z})
unloaded = unloaded or missing
if def and def.walkable ~= false then roof = true; break end
end
geometry.roof, geometry.unloaded = roof, unloaded
return geometry
end
local open_sides, ventilated = {}, false
local directions = {{1,0,0},{-1,0,0},{0,1,0},{0,-1,0},{0,0,1},{0,0,-1}}
while index <= #queue and index <= MAX_CELLS do
local p = queue[index]; index = index + 1
local cell_key = key(p)
if not seen[cell_key] then
seen[cell_key] = true
if p.x == minx then open_sides.west = true end
if p.x == maxx then open_sides.east = true end
if p.z == minz then open_sides.north = true end
if p.z == maxz then open_sides.south = true end
if p.y == maxy then open_sides.sky = true end
if p.y == miny then open_sides.floor = true end
ventilated = ventilated or has_vent_near(p)
for _, d in ipairs(directions) do
local npos = {x = p.x + d[1], y = p.y + d[2], z = p.z + d[3]}
if npos.x >= minx and npos.x <= maxx and npos.z >= minz and npos.z <= maxz
and npos.y >= miny and npos.y <= maxy then
local nkey = key(npos)
if not seen[nkey] then
local node, def, missing = read(npos)
unloaded = unloaded or missing
if node and not blocks_room(node, def) then queue[#queue + 1] = npos end
end
end
end
end
end
if index > MAX_CELLS then open_sides.sky = true end
local horizontal_open = (open_sides.west and 1 or 0) + (open_sides.east and 1 or 0)
+ (open_sides.north and 1 or 0) + (open_sides.south and 1 or 0)
local roof = not open_sides.sky
local enclosed = roof and not open_sides.floor and horizontal_open == 0
local roof_def
for y = 1, 5 do
local _, def, missing = read({x = head.x, y = head.y + y, z = head.z})
unloaded = unloaded or missing
if def and def.walkable ~= false then roof_def = true; break end
end
roof = roof_def == true
local closed_sides = 4 - horizontal_open
local shelter = enclosed and roof and 1 or (roof and (0.25 + closed_sides * 0.1875) or 0)
if unloaded then enclosed, shelter, ventilated = false, 0, true end
geometry = {roof = roof, shelter = math.max(0, math.min(1, shelter)), enclosed = enclosed,
ventilated = not enclosed or ventilated, open_sides = horizontal_open, unloaded = unloaded}
if cache_count >= CACHE_LIMIT then cache, cache_count = {}, 0 end
cache[cache_key] = {revision = revision, at = now, geometry = geometry}
cache_count = cache_count + 1
return geometry
end
function hearth.invalidate_environment()
revision = revision + 1
cache, cache_count = {}, 0
end
function hearth.environment(pos)
local room = room_geometry(pos)
local fire = C.find_node_near(pos, 4, {"hearth:fire_lit"})
local heat = 0
if fire then
local deadline = tonumber(C.get_meta(fire):get_string("fuel_until")) or 0
if M.fuel_remaining(deadline, os.time()) > 0 then
local head = vector.round({x = pos.x, y = pos.y + 1, z = pos.z})
local from = vector.offset(fire, 0, 0.6, 0)
if C.line_of_sight(from, head, 0.5) then heat = 1 - math.min(0.8, vector.distance(pos, fire) / 5) end
end
end
return {roof = room.roof, shelter = room.shelter, enclosed = room.enclosed,
ventilated = room.ventilated, open_sides = room.open_sides, heat = heat,
smoke = heat > 0 and room.enclosed and not room.ventilated,
rain = hearth.weather == "rain", temperature = hearth.clock.temperature - math.max(0, pos.y) * 0.0065}
end
+143
View File
@@ -0,0 +1,143 @@
-- M1 presentation only: no metadata, persisted preferences, chat or camera bob.
local C = core
local frames = {idle = {0, 19, 12}, walk = {20, 39, 24}, run = {40, 59, 32},
strike = {60, 69, 28}, guard = {70, 79, 12}, swim = {80, 99, 20}}
local function now() return C.get_us_time() / 1000000 end
local function session(player) return hearth.sessions[player:get_player_name()] end
local function text(value)
-- Bound HUD payloads and strip control/escape sequences from item descriptions.
return tostring(value or ""):gsub("\27%([^)]*%)", ""):gsub("[%c]", " "):sub(1, 200)
end
function hearth.feedback_sound(name, player, gain)
local spec = {pos = player:get_pos(), gain = gain or 0.25, max_hear_distance = 12}
-- Optional sound adapter contract: sound(name, SoundParameters, ephemeral).
if hearth.sound then return hearth.sound(name, spec, true) end
return C.sound_play(name, spec, true)
end
local colors = {info = 0xcdd6f4, success = 0xa6e3a1, warning = 0xf9e2af, error = 0xf38ba8}
function hearth.feedback(player, message, kind)
local s = session(player); if not s then return end
s.feedback_text, s.feedback_kind, s.feedback_until = text(message), kind, now() + 3
end
local function target_text(player)
local pos, look = player:get_pos(), player:get_look_dir()
local props = player:get_properties()
local start = {x = pos.x, y = pos.y + (props.eye_height or 1.55), z = pos.z}
local finish = {x = start.x + look.x * 5, y = start.y + look.y * 5, z = start.z + look.z * 5}
-- No object hits and no liquids: water never obscures useful node targeting.
for hit in C.raycast(start, finish, false, false) do
if hit.type == "node" then
local node = C.get_node_or_nil and C.get_node_or_nil(hit.under) or nil
if not node or node.name == "ignore" then return "" end
local def = C.registered_nodes[node.name]
if not def then return "" end
if node.name == "hearth:fire" or node.name == "hearth:fire_lit" then
local status = C.get_meta(hit.under):get_string("infotext")
return text(def.description) .. (status ~= "" and (" | " .. text(status)) or "")
end
local B = hearth.botany
local known = B and B.kind and B.kind(node.name)
local inspect_hint = known and " | /inspect species" or ""
local wield = player:get_wielded_item()
local caps = wield:get_tool_capabilities()
local dig = C.get_dig_params(def.groups or {}, caps, wield:get_wear())
local wield_def = C.registered_nodes[wield:get_name()]
if wield_def and hearth.construction then
local destination = def.buildable_to and hit.under or hit.above
local ok, reason = hearth.construction.preview(destination, player, wield:get_name())
local reasons = {ok = "Ready to place", support = "Needs nearby structural support",
protected = "Protected homestead", unloaded = "Terrain not loaded", occupied = "Space occupied",
distance = "Move closer", item = "Not a building material", invalid = "Invalid position"}
return text(wield_def.description):sub(1,120) .. " | " .. text(reasons[reason] or (ok and "Ready to place" or "Cannot place here")) .. inspect_hint
end
return text(def.description):sub(1,140)
.. (dig.diggable and " | Tool suitable" or " | Different tool needed")
.. inspect_hint
end
end
return ""
end
local function surface(player, s)
if s.swimming then return "water" end
local p = player:get_pos()
local feet = C.registered_nodes[C.get_node({x = p.x, y = p.y + 0.1, z = p.z}).name] or {}
if feet.groups and (feet.groups.water or 0) > 0 then return "water" end
local def = C.registered_nodes[C.get_node({x = p.x, y = p.y - 0.1, z = p.z}).name] or {}
local g = def.groups or {}
if (g.water or 0) > 0 then return "water" end
if (g.wood or 0) > 0 or (g.tree or 0) > 0 then return "wood" end
if (g.stone or 0) > 0 or (g.cracky or 0) > 0 then return "stone" end
return "soil"
end
function hearth.feedback_step(player, dt)
if hearth.presentation_step then hearth.presentation_step(player) end
local s = session(player); if not s or s.waiting or player:get_hp() <= 0 then return end
dt = hearth.movement.delta(dt)
local t, pos, velocity = now(), player:get_pos(), player:get_velocity()
local controls = player:get_player_control()
-- Rising edge only; holding dig never replays a strike every frame. Combat
-- may set session.strike_until using the same monotonic seconds clock.
if controls.LMB and not s.feedback_lmb then
s.strike_until = t + 10 / 28
local swing = ({["hearth:knife"] = "hearth_swing_knife",
["hearth:axe"] = "hearth_swing_axe", ["hearth:spear"] = "hearth_swing_spear"})
local sound = swing[player:get_wielded_item():get_name()]
if sound then hearth.feedback_sound(sound, player, 0.18) end
end
s.feedback_lmb = controls.LMB
local animation = s.swimming and "swim" or (s.blocking and "guard"
or ((s.strike_until or 0) > t and "strike" or (s.moving and (s.sprinting and "run" or "walk") or "idle")))
if s.animation ~= animation then
local f = frames[animation]
player:set_animation({x = f[1], y = f[2]}, f[3], 0.12, animation ~= "strike")
s.animation = animation
end
-- FOV opt-in only. Default comfort_fov=nil never touches client settings.
if s.comfort_fov ~= nil and s.applied_fov ~= s.comfort_fov then
player:set_fov(s.comfort_fov, false, 0.2); s.applied_fov = s.comfort_fov
end
local previous = s.feedback_pos
s.step_time = (s.step_time or 0) + dt
if s.grounded and s.feedback_grounded == false and (s.feedback_fall or 0) < -3 then
hearth.feedback_sound("hearth_land", player, 0.35)
s.step_time, s.step_distance = 0, 0
elseif previous and s.grounded and s.feedback_grounded and s.moving then
local distance = math.sqrt((pos.x - previous.x)^2 + (pos.z - previous.z)^2)
-- Ignore teleport deltas rather than bursting queued footfalls.
if distance <= 2 then s.step_distance = (s.step_distance or 0) + distance end
if s.step_time >= (s.sprinting and 0.28 or 0.4) and (s.step_distance or 0) >= 1.4 then
hearth.feedback_sound("hearth_step_" .. surface(player, s), player)
s.step_time, s.step_distance = 0, 0
end
elseif not s.moving or not s.grounded then s.step_distance = 0 end
if s.grounded then s.feedback_fall = 0
else s.feedback_fall = math.min(s.feedback_fall or 0, velocity.y) end
s.feedback_pos = {x = pos.x, y = pos.y, z = pos.z}
s.feedback_grounded = s.grounded
-- Bounded single-entry target cache; all raycasts/HUD traffic <= 10 Hz.
if t < (s.feedback_next or 0) then return end
s.feedback_next = t + 0.1
local message = (s.feedback_until or 0) > t and s.feedback_text or target_text(player)
local color = (s.feedback_until or 0) > t and (colors[s.feedback_kind] or colors.info) or colors.info
if not s.feedback_hud then
s.feedback_hud = player:hud_add({type = "text", position = {x = 0.5, y = 0.72},
offset = {x = 0, y = 0}, alignment = {x = 0, y = 0}, number = color, text = message})
elseif message ~= s.feedback_display then player:hud_change(s.feedback_hud, "text", message) end
if s.feedback_color and color ~= s.feedback_color then player:hud_change(s.feedback_hud, "number", color) end
s.feedback_display, s.feedback_color = message, color
end
C.register_chatcommand("comfort", {
params = "[default|fov <60..100>]", description = "Session-only static FOV; no imposed camera bob",
func = function(name, param)
local player = C.get_player_by_name(name)
local s = player and session(player)
if not s then return false, "Player session unavailable." end
if param == "" then return true, "FOV unchanged by default. Use /comfort fov 75 or /comfort default." end
local value = param:match("^fov (%d+)$")
value = value and tonumber(value)
if param == "default" then value = 0
elseif not value or value < 60 or value > 100 then return false, "Use default or fov 60..100." end
s.comfort_fov = value
return true, "Session camera preference updated."
end,
})
+162
View File
@@ -0,0 +1,162 @@
-- Bounded, bait-funded riverside fishing. A trap can hold one catch at a time.
return function(hearth, api, construction)
local CATCH_SECONDS = 600
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
return value
end
local function safe_now()
local value = os.time()
if type(value) ~= "number" or value ~= value or value < 0 or value == math.huge or value == -math.huge then return 0 end
return math.floor(value)
end
local function node(pos)
local value = api.get_node_or_nil(pos)
if not value or value.name == "ignore" then return nil end
return value
end
local function has_water(pos)
for y = -1, 1 do for x = -3, 3 do for z = -3, 3 do
local nearby = node({x = pos.x + x, y = pos.y + y, z = pos.z + z})
if nearby and (nearby.name == "hearth:water_source" or nearby.name == "hearth:water_flowing") then
return true
end
end end end
return false
end
local function valid_site(pos)
local here = node(pos)
local below = node({x = pos.x, y = pos.y - 1, z = pos.z})
local here_def = here and api.registered_nodes[here.name]
return here_def and here_def.buildable_to == true and below
and (below.name == "hearth:sand" or below.name == "hearth:grass")
and has_water(pos)
end
local function clear_job(meta)
meta:set_string("state", "idle")
meta:set_string("ready_at", "0")
meta:set_int("catch_count", 0)
end
local function valid_catch(meta)
local count = meta:get_int("catch_count")
return count == 1 or count == 2
end
local function finish(pos, meta, completed_at)
local season = hearth.climate_at and hearth.climate_at(completed_at)
local count = season and season.season == "Spring" and 2 or 1
meta:set_string("state", "ready")
meta:set_string("ready_at", "0")
meta:set_int("catch_count", count)
meta:set_string("infotext", count == 2
and "River trap: spring run caught two fish; right-click empty-handed to collect."
or "River trap: catch ready; right-click empty-handed to collect.")
api.get_node_timer(pos):stop()
end
local function reconcile(pos, now)
local current = node(pos)
if not current or current.name ~= "hearth:fish_trap" then return false end
local meta = api.get_meta(pos)
local state = meta:get_string("state")
if state == "ready" then
if not valid_catch(meta) then clear_job(meta) end
return false
end
if state ~= "baited" then
clear_job(meta)
return false
end
local ready = finite(meta:get_string("ready_at"))
now = now or safe_now()
if not ready or ready <= 0 or ready > now + CATCH_SECONDS then
clear_job(meta)
meta:set_string("infotext", "River trap: ready for bait beside water.")
api.get_node_timer(pos):stop()
return false
end
if ready <= now then
finish(pos, meta, ready)
return false
end
api.get_node_timer(pos):start(math.max(1, ready - now))
meta:set_string("infotext", "River trap: baited; catch in " .. math.ceil(ready - now) .. " seconds.")
return true
end
local function plant_trap(stack, player, pointed)
if not pointed or pointed.type ~= "node" or not player or not player:is_player() then return stack end
local pos = pointed.above
if not valid_site(pos) then
hearth.notify(player, "Set a fish trap on firm riverbank within three blocks of water.")
return stack
end
if not construction.allowed(pos, player, "build") then return stack end
api.set_node(pos, {name = "hearth:fish_trap"})
local def = api.registered_nodes["hearth:fish_trap"]
if def and def.after_place_node then def.after_place_node(pos, player) end
stack:take_item(1)
return stack
end
local function collect(pos, player, meta)
local count = meta:get_int("catch_count")
if count ~= 1 and count ~= 2 then clear_job(meta); return end
local inventory = player:get_inventory()
local output = ItemStack("hearth:raw_fish " .. count)
if not inventory or not inventory:room_for_item("main", output) then
hearth.notify(player, "Make room for the catch before emptying the river trap.")
return
end
local leftover = inventory:add_item("main", output)
if leftover and not leftover:is_empty() then
meta:set_int("catch_count", leftover:get_count())
hearth.notify(player, "Some fish were collected; make room for the rest.")
return
end
clear_job(meta)
meta:set_string("infotext", "River trap: ready for bait beside water.")
end
local function rightclick(pos, _, player, stack)
if not player or not player:is_player() or not construction.allowed(pos, player, "use") then return stack end
local meta = api.get_meta(pos)
local state = meta:get_string("state")
if state == "ready" then
if stack:is_empty() then collect(pos, player, meta) else
hearth.notify(player, "Collect the waiting catch before baiting this trap again.")
end
return stack
end
if stack:get_name() ~= "hearth:fish_bait" then
local remaining = state == "baited"
and math.max(0, (finite(meta:get_string("ready_at")) or safe_now()) - safe_now()) or nil
hearth.notify(player, remaining and ("River trap: waiting " .. remaining .. " seconds.")
or "River trap: add fish bait to set it for ten minutes.")
return stack
end
if state == "baited" then
hearth.notify(player, "This trap is already baited; return after the catch is ready.")
return stack
end
if not has_water(pos) then
hearth.notify(player, "This trap is too far from water to catch fish.")
return stack
end
local now = safe_now()
meta:set_string("state", "baited")
meta:set_string("ready_at", tostring(now + CATCH_SECONDS))
meta:set_int("catch_count", 0)
meta:set_string("baiter", player:get_player_name())
api.get_node_timer(pos):start(CATCH_SECONDS)
meta:set_string("infotext", "River trap: baited; catch in " .. CATCH_SECONDS .. " seconds.")
stack:take_item(1)
return stack
end
local function on_timer(pos)
return reconcile(pos)
end
local function can_dig(pos, player)
return construction.allowed(pos, player, "dig")
and api.get_meta(pos):get_string("state") == "idle"
end
return {plant_trap = plant_trap, rightclick = rightclick, reconcile = reconcile,
on_timer = on_timer, can_dig = can_dig, has_water = has_water,
CATCH_SECONDS = CATCH_SECONDS}
end
+177 -17
View File
@@ -10,27 +10,152 @@ function hearth.notify(player, message)
end
local storage = core.get_mod_storage()
hearth.storage = storage
hearth.schema = dofile(path .. "/schema.lua")
hearth.persistence = dofile(path .. "/persistence.lua")(hearth.schema, core, storage)
hearth.movement = dofile(path .. "/movement.lua")
hearth.combat = dofile(path .. "/combat.lua")
-- A small claim registry is persisted in mod storage; never deserialize code.
local claim_data = storage:get_string("claims")
local claims = claim_data ~= "" and core.parse_json(claim_data) or {}
function hearth.claim_allowed(pos, name)
for owner, p in pairs(claims) do
if owner ~= name and math.abs(pos.x - p.x) <= 12 and math.abs(pos.z - p.z) <= 12
and math.abs(pos.y - p.y) <= 24 then return false end
local claims = hearth.persistence.load_world("claims")
local homes = hearth.persistence.load_world("homes")
local claim_access = hearth.persistence.load_world("access")
local access_policy = dofile(path .. "/access.lua")(claims, claim_access)
hearth.claim_owner_at = access_policy.owner_at
hearth.claim_role_at = access_policy.role_at
hearth.claim_allowed = access_policy.allowed
function hearth.home_for(name)
local home = homes[name]
return home and {x = home.x, y = home.y, z = home.z} or nil
end
function hearth.store_home(name, pos)
if type(name) ~= "string" or name == "" or not hearth.in_own_claim
or not hearth.in_own_claim(pos, name) then
error("Last Hearth: home save denied", 0)
end
local replacement = {}
for owner, home in pairs(homes) do replacement[owner] = {x = home.x, y = home.y, z = home.z} end
replacement[name] = {x = pos.x, y = pos.y, z = pos.z}
hearth.persistence.save_world("homes", replacement)
homes = replacement
end
function hearth.in_own_claim(pos, name)
return claims[name] ~= nil and hearth.claim_owner_at(pos) == name
end
local old_protected = core.is_protected
hearth.external_protected = old_protected
core.is_protected = function(pos, name)
return not hearth.claim_allowed(pos, name, "build") or old_protected(pos, name)
end
local function clone_positions(source)
local result = {}
for owner, pos in pairs(source) do result[owner] = {x = pos.x, y = pos.y, z = pos.z} end
return result
end
local function replace_map(target, source)
for key in pairs(target) do target[key] = nil end
for key, value in pairs(source) do
if type(value) == "table" then
local copy = {}
for k, v in pairs(value) do copy[k] = v end
target[key] = copy
else target[key] = value end
end
end
local function commit_claims(replacement)
hearth.persistence.save_world("claims", replacement)
replace_map(claims, replacement)
end
local function commit_access(replacement)
hearth.persistence.save_world("access", replacement)
replace_map(claim_access, replacement)
end
local function valid_player_name(value)
return type(value) == "string" and #value > 0 and #value <= 32
and value:match("^[%w_%-]+$") ~= nil
end
local function draw_claim(player, center)
if not core.add_particle then return false end
local name = player:get_player_name()
local function mark(x, z)
core.add_particle({pos = {x = x, y = center.y + 0.2, z = z},
velocity = {x = 0, y = 0.15, z = 0}, acceleration = {x = 0, y = 0, z = 0},
expirationtime = 6, size = 1.8, texture = hearth.texture("#e5c890"),
glow = 8, playername = name})
end
for offset = -12, 12, 2 do
mark(center.x + offset, center.z - 12); mark(center.x + offset, center.z + 12)
if offset > -12 and offset < 12 then
mark(center.x - 12, center.z + offset); mark(center.x + 12, center.z + offset)
end
end
return true
end
local old_protected = core.is_protected
core.is_protected = function(pos, name)
return not hearth.claim_allowed(pos, name) or old_protected(pos, name)
end
core.register_chatcommand("claim", {
description = "Protect a 25 x 25 homestead here (one per player). /claim remove to release.",
params = "[show|list|share <player> <resident|storekeeper>|unshare <player>|remove]",
description = "Protect and manage a homestead; /claim show briefly marks its boundary.",
func = function(name, param)
local player = core.get_player_by_name(name)
if not player then return false, "Join the world first." end
if param == "remove" then
claims[name] = nil
if param == "show" or param == "list" then
local center = claims[name]
if not center then
center = hearth.claim_owner_at(player:get_pos()) and claims[hearth.claim_owner_at(player:get_pos())]
end
if not center then return false, "No homestead is under you. Use /claim to create one." end
if param == "show" then
if not hearth.claim_allowed(center, name, "use") then return false, "This homestead is private." end
draw_claim(player, center)
return true, string.format("Homestead bounds: X %d..%d, Z %d..%d, Y %d..%d.",
center.x - 12, center.x + 12, center.z - 12, center.z + 12, center.y - 24, center.y + 24)
end
if not claims[name] then return false, "Only the homestead owner can list its members." end
local members = claim_access[name] or {}
local names = {}
for member, role in pairs(members) do names[#names + 1] = member .. " (" .. role .. ")" end
table.sort(names)
return true, #names == 0 and "No shared members. Use /claim share <player> <resident|storekeeper>." or table.concat(names, ", ")
elseif param == "remove" then
if not claims[name] then return false, "You have no homestead to release." end
local new_access = {}
for owner, members in pairs(claim_access) do if owner ~= name then new_access[owner] = members end end
commit_access(new_access) -- Revoke first; a later failure cannot leave stale grants.
local new_homes = {}
for owner, home in pairs(homes) do if owner ~= name then new_homes[owner] = home end end
hearth.persistence.save_world("homes", new_homes); replace_map(homes, new_homes)
local new_claims = clone_positions(claims); new_claims[name] = nil
commit_claims(new_claims)
return true, "Homestead released; shared access and its saved home were removed."
elseif param:match("^share%s+") then
local member, role = param:match("^share%s+(%S+)%s+(%S+)$")
if not claims[name] then return false, "Create your homestead before sharing it." end
if not valid_player_name(member) or member == name or claims[member] then
return false, "Choose another valid player name; claim owners cannot be assigned as members."
end
if role ~= "resident" and role ~= "storekeeper" then
return false, "Role must be resident or storekeeper."
end
local new_access = {}
for owner, members in pairs(claim_access) do
new_access[owner] = {}; for who, value in pairs(members) do new_access[owner][who] = value end
end
new_access[name] = new_access[name] or {}
if not new_access[name][member] then
local count = 0; for _ in pairs(new_access[name]) do count = count + 1 end
if count >= 16 then return false, "A homestead can have at most 16 shared members." end
end
new_access[name][member] = role
commit_access(new_access)
return true, member .. " may access this homestead as " .. role .. "."
elseif param:match("^unshare%s+") then
local member = param:match("^unshare%s+(%S+)$")
if not claims[name] or not valid_player_name(member) then return false, "Use /claim unshare <player> from your own homestead." end
local new_access = {}
for owner, members in pairs(claim_access) do
new_access[owner] = {}; for who, value in pairs(members) do if not (owner == name and who == member) then new_access[owner][who] = value end end
if not next(new_access[owner]) then new_access[owner] = nil end
end
commit_access(new_access)
return true, member .. " no longer has access to your homestead."
elseif param == "" then
local p = vector.round(player:get_pos())
if claims[name] then return false, "Release your existing claim before moving it." end
@@ -39,13 +164,48 @@ core.register_chatcommand("claim", {
return false, "Too close to another homestead. Move at least 26 blocks away."
end
end
claims[name] = {x = p.x, y = p.y, z = p.z}
else return false, "Use /claim or /claim remove." end
storage:set_string("claims", core.write_json(claims))
return true, param == "remove" and "Homestead released." or "Homestead protected. Others cannot build or harvest here."
local new_claims = clone_positions(claims)
new_claims[name] = {x = p.x, y = p.y, z = p.z}
commit_claims(new_claims)
draw_claim(player, p)
return true, "Homestead protected. Use /claim show to see its temporary boundary markers."
else return false, "Use /claim, /claim show, /claim list, /claim share, /claim unshare, or /claim remove." end
end,
})
local function homes_disabled()
return core.settings and core.settings.get_bool
and core.settings:get_bool("hearth_m1_course", false) == true
end
core.register_chatcommand("sethome", {
description = "Save a safe home inside your own homestead claim.",
func = function(name)
if homes_disabled() then return false, "Homes are disabled in the disposable M1 course. Use /course retry." end
local player = core.get_player_by_name(name)
if not player then return false, "Join the world first." end
if not hearth.set_home then return false, "Home services are still starting." end
return hearth.set_home(player)
end,
})
core.register_chatcommand("home", {
description = "Return to your verified personal home.",
func = function(name)
if homes_disabled() then return false, "Homes are disabled in the disposable M1 course. Use /course retry." end
local player = core.get_player_by_name(name)
if not player then return false, "Join the world first." end
if not hearth.go_home then return false, "Home services are still starting." end
return hearth.go_home(player)
end,
})
dofile(path .. "/content.lua")
hearth.character = dofile(path .. "/character.lua")(hearth, core)
dofile(path .. "/interface.lua")
dofile(path .. "/presentation.lua")
dofile(path .. "/feedback.lua")
dofile(path .. "/clock.lua")
dofile(path .. "/environment.lua")
dofile(path .. "/players.lua")
dofile(path .. "/runtime.lua")
dofile(path .. "/encounters.lua")
-- Enroll the fresh, nonce-marked fixture after core systems exist but before
-- mods-loaded / player callbacks. Course creation remains explicit /course consent.
dofile(path .. "/course.lua")
+26
View File
@@ -0,0 +1,26 @@
-- Player interactions; survival state remains owned by the caller.
return function(hearth)
local function eat(amounts, replacement)
return function(stack, player)
if not player or not player:is_player() then return stack end
local state = hearth.players[player:get_player_name()]
if not state then return stack end
local needed = false
for key, value in pairs(amounts) do
if type(state[key]) == "number" and state[key] < 100 and value > 0 then
needed = true
end
end
if not needed then return stack end
for key, value in pairs(amounts) do
if type(state[key]) == "number" then
state[key] = math.max(0, math.min(100, state[key] + value))
end
end
if replacement then stack:replace(replacement) else stack:take_item() end
return stack
end
end
return {eat = eat}
end
+477 -53
View File
@@ -1,79 +1,475 @@
local C = core
local esc = C.formspec_escape
local bg, text, dim, gold = "#1e1e2e", "#cdd6f4", "#a6adc8", "#e5c890"
-- 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"}
local function label(x, y, value, color)
return "label[" .. x .. "," .. y .. ";" .. esc(C.colorize(color or ink.text, value)) .. "]"
end
local function divider(x, y, width)
-- A little pressed sprig: a stem, paired leaves, then quiet ruled paper.
return "box[" .. x .. "," .. y .. ";" .. width .. ",0.02;" .. ink.border .. "]"
.. "box[" .. x .. "," .. (y-0.01) .. ";0.72,0.04;" .. ink.teal .. "]"
.. "box[" .. (x+0.16) .. "," .. (y-0.09) .. ";0.16,0.07;" .. ink.teal .. "]"
.. "box[" .. (x+0.39) .. "," .. (y+0.04) .. ";0.16,0.07;" .. ink.teal .. "]"
end
local function journal(width, height)
return "formspec_version[6]size[" .. width .. "," .. height .. "]bgcolor[" .. ink.base .. ";true]"
.. "box[0,0;" .. width .. "," .. height .. ";" .. ink.base .. "]"
.. "style_type[label;textcolor=" .. ink.text .. "]"
.. "style_type[button,button_exit;border=false;bgimg=" .. esc("[fill:1x1:#ffffff") .. ";bgcolor=" .. ink.raised .. ";textcolor=" .. ink.text .. "]"
.. "style_type[button:hovered,button_exit:hovered;bgcolor=" .. ink.border .. ";textcolor=" .. ink.teal .. "]"
.. "style_type[button:pressed,button_exit:pressed;bgcolor=" .. ink.mauve .. ";textcolor=" .. ink.mantle .. "]"
.. "style_type[field,textarea;textcolor=" .. ink.text .. ";border=false]"
.. "style_type[list;size=1,1;spacing=0.12,0.12]"
.. "listcolors[" .. ink.raised .. ";" .. ink.border .. ";" .. ink.border .. ";" .. ink.mantle .. ";" .. ink.text .. "]"
end
local function index(value, maximum)
value = tonumber(value)
if not value or value ~= value then return 1 end
return math.max(1, math.min(maximum, math.floor(value)))
end
local reader = dofile(C.get_modpath("hearth") .. "/species_reader.lua")(hearth, C, journal)
hearth.species_reader = reader
local function inspect(player, param)
if not hearth.sessions[player:get_player_name()] then return false, "Join the world first." end
local r
if param == "" then r = reader.look(player) else r = reader.resolve(param) end
if not r then return false, "Look at a known species within six blocks, or use /inspect <exact ID or Latin name>." end
return reader.show(player,r.id)
end
local pages = {
{"First light", "YOUR FIRST HEARTH", "Start with what the valley gives you.",
"Collect loose flint and dry sticks from the ground. Gather berries for food and leaves for extra sticks.\n\nOpen your inventory with I. Make a flint axe, then fell timber. Split logs into planks; craft walls and a thatch roof.\n\nA roof keeps the rain off. Walls stop exposure. A fueled hearth dries and warms you. Leave a small opening: enclosed fires need ventilation.\n\nThere is no quest timer. Your first success is a home that works."},
{"Craft & build", "WORK WITH YOUR HANDS", "All recipes use the 3 x 3 inventory grid.",
"AXE: two flints across the top; flint + stick below; a stick beneath the handle.\nKNIFE: two flints above one stick (right column).\nSPEAR: flint above two sticks.\nCUP: flint at top-left and top-right, flint at center.\nHEARTH: eight flints around an empty center.\n\nOne log -> four planks. One plank -> four sticks. Four planks in a square -> four walls.\nROOF: three reeds across the top, two sticks at the ends of the next row.\n\nWalls and roofs need support within two horizontal blocks. Columns may be up to 16 blocks high. Existing unsupported blocks do not collapse in this prototype."},
"Collect loose flint and dry sticks from the ground. Gather berries for food and leaves for extra sticks.\n\nOpen your inventory with I. Make a flint axe, then fell timber. Split logs into planks; craft walls and a thatch roof.\n\nA roof keeps the rain off. Walls stop exposure. Fuel a cold hearth, then ignite it with the reusable flint firestarter. A torch brings portable light to the long real-world night.\n\nUse /claim to protect your homestead, then /sethome from a safe, dry position inside the claim.\n\nThere is no quest timer. Your first success is a home that works."},
{"Craft & build", "WORK WITH YOUR HANDS", "Search recipes in the recipe index.",
"Open the recipe index from any field-note page, or type /recipes <word> in chat. Search checks both ingredients and results. The inventory crafting preview shows the actual matching result.\n\nBuild from stone foundations and timber frames to walls, roofs, stairs, doors and reed-lattice windows. Right-click a door to open or close it. Roof vents keep enclosed hearth rooms breathable.\n\nHarvest wild grain and replant seed. Berries regrow on their bush; saplings can regrow a tree when planted in clear space. Crude iron ore is found in deep stone; combine a log and clay into a charcoal charge and process it at a lit hearth. Combine charcoal and ore into a smelting charge for a fueled hearth; an iron axe upgrades timber work.\n\nDry cooked food with reeds into shelf-stable rations; fire a clay jar and combine it with planks to make a pantry. General spoilage remains disabled."},
{"Fire & food", "A FIRE WORTH KEEPING", "Gather. Prepare. Eat. Recover.",
"Place your stone hearth. Right-click it with sticks or logs to add fuel. Each stick burns for 45 real seconds; a log for three minutes.\n\nCraft two berries into forage. Right-click a fueled hearth with forage to cook; left-click with cooked food to eat. Berries can also be eaten raw.\n\nUse the stone cup on river water to fill it. Right-click a fueled hearth with untreated water to boil it. Left-click to drink boiled water.\n\nCooking is a fuel-consuming instant prototype, not a realistic timed process. No spoilage, farming or pottery yet."},
"Place a cold stone hearth. Right-click with sticks or logs to add fuel, then use a flint firestarter on it. Fuel burns in real time: one stick is 45 seconds; a log is three minutes. A torch is a permanent, portable light.\n\nCraft two berries into forage. Right-click the lit hearth with one raw portion to start a six-second cook. One untreated cup of river water takes four seconds to boil. The full heat cost is reserved on start; fuel continues to burn during the work time.\n\nWhen the infotext says the output is ready, right-click the hearth empty-handed to collect it. The output remains there if your inventory is full. Left-click cooked food or clean water to eat/drink. Smelting takes 60 seconds, pottery firing 120, and drying 300; each reserves its heat cost before consuming the charge.\n\nProcesses use real elapsed seconds and reconcile once after absence; absent players do not need to tend them. General food spoilage is disabled; dried rations are shelf-stable."},
{"Read the world", "SHELTER IS A SYSTEM", "The HUD tells you why you feel the way you do.",
"A covered roof plus nearby walls reduces exposure. Wetness rises in rain and falls under cover; a hearth helps you dry. Check the shelter indicator as you build.\n\nA very enclosed fire carries a smoke warning and causes harm: provide ventilation. This is a bounded enclosure approximation, not smoke simulation.\n\nAux1 (usually E) sprints. Right-click while holding a spear to brace against the practice opponent; bracing costs stamina and reduces walking speed.\n\nApproach the practice target near arrival. It retaliates only after you strike it. Step outside its short reach to recover. PvP is disabled."},
"Shelter is measured by a bounded room probe, not just four wall rays. Closed doors and windows stop weather; an open doorway exposes that side. A roof vent or an open door lets smoke escape. An enclosed room with an unvented hearth is dangerous.\n\nRain wets exposed clothing. A woven cloak slows wetting and adds warmth. Dry indoors beside a lit hearth or at a drying rack. The room probe is bounded and cached; very large or unloaded spaces fail safely as exposed.\n\nAux1 (usually E) sprints. Right-click while holding a spear to brace; bracing costs stamina and reduces walking speed.\n\nOn the disposable M1 course, use /course to create and start the marked movement route. The stationary /practice target or opt-in moving /spar opponent starts combat only when you choose. Face the opponent and strike; step aside or back to evade, or face it and brace with spear + RMB. PvP is disabled."},
{"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. One claim per person. /claim remove releases it. Stand at least 26 blocks from another claim.\n\nYour inventory and needs persist between sessions. Logged-out characters do not starve or freeze. Fuel continues to expire in real time.\n\nDeath keeps your inventory and returns you to arrival, with needs partially restored. This forgiving prototype policy will be revisited after playtests.\n\nThis is not yet a hardened public server: moderation, shared claim membership and all grief scenarios need further testing."},
"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."},
{"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."},
}
function hearth.guide(player, page)
page = math.max(1, math.min(#pages, page or 1))
hearth.sessions[player:get_player_name()].page = page
local p = pages[page]
local f = {"formspec_version[6]size[14,10]bgcolor[", bg, ";true]box[0,0;14,10;#1e1e2eff]",
"style_type[button;border=false;bgcolor=#313244;textcolor=", text, "]",
"box[0,0;3.3,10;#181825]label[0.45,0.65;", esc("eom.dev / SEASON VI"), "]",
"label[0.45,1.3;", esc("THE LAST HEARTH"), "]"}
for i, pg in ipairs(pages) do
f[#f + 1] = "button[0.35," .. (2 + i * 0.72) .. ";2.6,0.58;page_" .. i .. ";" .. esc(pg[1]) .. "]"
local botanical_groups = {
{"trees", "Trees"}, {"shrubs", "Shrubs"}, {"grasses", "Grasses, sedges & rushes"},
{"herbs", "Herbs"}, {"ferns_mosses", "Ferns & mosses"}, {"fungi", "Fungi"},
{"aquatic", "Aquatic plants"}, {"lichens", "Lichens"},
}
local function catalogue()
local botany = hearth.botany
return botany and type(botany.list) == "function" and botany.list() or {}
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."
for _, group in ipairs(botanical_groups) do
local names = {}
for _, r in ipairs(records) do
if r.group == group[1] then names[#names+1] = r.common .. " (" .. r.latin .. ")" end
end
lines[#lines+1] = group[2] .. " / " .. #names .. ": " .. table.concat(names, "; ")
end
f[#f + 1] = "label[3.85,0.7;" .. esc("FIELD NOTES / " .. string.format("%02d", page)) .. "]"
f[#f + 1] = "style[title;textcolor=" .. gold .. "]label[3.85,1.35;" .. esc(p[2]) .. "]"
f[#f + 1] = "label[3.85,2.0;" .. esc(p[3]) .. "]"
f[#f + 1] = "textarea[3.8,2.7;9.65,5.6;;;" .. esc(p[4]) .. "]"
f[#f + 1] = "button_exit[10.5,8.85;2.8,0.65;close;Back to the valley]"
lines[#lines+1] = "Game-only field notes, not a real-world forage guide. Toxic is a catalogue flag; an unflagged species is NOT a food-safety recommendation. Roles describe conditional policy, not universal active effects."
return table.concat(lines, "\n\n")
end
function hearth.guide(player, page)
local session = hearth.sessions[player:get_player_name()]
if not session then return end
page = index(page, #pages)
session.page = page
local p = pages[page]
local f = {journal(14, 10), "box[0,0;3.3,10;" .. ink.mantle .. "]",
label(0.4,0.55,"eom.dev / SEASON VI",ink.mauve), label(0.4,1.15,"THE LAST HEARTH"),
divider(0.4,1.65,2.5), label(0.4,2.05,"FIELD JOURNAL",ink.muted)}
for i, pg in ipairs(pages) do
if i == page then f[#f+1] = "style[page_" .. i .. ";bgcolor=" .. ink.border .. ";textcolor=" .. ink.teal .. "]" end
f[#f + 1] = "button[0.35," .. (2.45 + (i-1)*0.68) .. ";2.6,0.56;page_" .. i .. ";" .. esc(pg[1]) .. "]"
end
f[#f+1] = label(0.4,8.95,"Observe. Make. Belong.",ink.muted)
f[#f + 1] = label(3.85,0.55,"FIELD NOTES / " .. string.format("%02d / %02d",page,#pages),ink.mauve)
f[#f + 1] = label(3.85,1.2,p[2],ink.yellow) .. label(3.85,1.85,p[3],ink.muted)
f[#f + 1] = divider(3.85,2.3,9.45)
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[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 .. "]"
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 .. "]"
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
local recipe_outputs = {"hearth:knife", "hearth:axe", "hearth:pick", "hearth:spear", "hearth:iron_axe",
"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: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",
"hearth:window", "hearth:vent", "hearth:storage", "hearth:pantry", "hearth:drying_rack", "hearth:cloak",
"hearth:grain_seed", "hearth:charcoal_charge", "hearth:smelt_charge", "hearth:clay_jar_charge", "hearth:dried_food_charge",
"hearth:bedroll", "hearth:planks", "hearth:sticks", "hearth:fish_trap", "hearth:fish_bait",
"hearth:fish_drying_charge", "hearth:pantry", "hearth:raw_food"}
local process_notes = {
{"Seasonal forage", "Spring greens grow in moist sunny gaps only in Spring and can be eaten. Autumn mushrooms appear in moist shade only in Autumn; craft one into raw food and cook it. Patches rest one real day after picking; changing seasons never resets the harvest."},
{"Deciduous woodland", "Spring buds become green summer leaves, copper and gold autumn foliage, then bare winter branches. Leaf fall admits more light. Perennial groundcover rests through winter; wildflowers bloom in Spring and Summer. Use /ecosystem for a read-only survey."},
{"Cooked forage", "One portion raw food + lit hearth: 6 real seconds; 20 fuel seconds reserved at start."},
{"Boiled water", "One cup untreated water + lit hearth: 4 real seconds; 10 fuel seconds reserved at start."},
{"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."},
{"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."},
{"River fish trap", "Craft from three sticks and three reeds. Place on firm sand or grass within three blocks of river water, then use one bait to set it for 600 real seconds. One catch per trap; a full inventory leaves the catch waiting."},
{"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."},
{"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."},
{"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."},
{"Ironworking", "Find deep iron-bearing rock, prepare charcoal from a log, then combine ore and charcoal into a smelting charge. A fueled hearth smelts it into an ingot for a stronger iron axe."},
}
local function item_label(item)
if type(item) ~= "string" or item == "" then return "" end
local name, count = item:match("^(%S+)%s*(%d*)$")
local def = name and C.registered_items[name]
local label = def and def.description or name or item
if count and count ~= "" and tonumber(count) and tonumber(count) > 1 then label = count .. " x " .. label end
return label
end
local function recipe_rows(query)
local rows, needle = {}, query:lower()
for _, output_name in ipairs(recipe_outputs) do
local output = C.registered_items[output_name]
local recipes = C.get_all_craft_recipes and C.get_all_craft_recipes(output_name) or nil
if output and recipes then
for _, recipe in ipairs(recipes) do
if recipe.method == "normal" then
local ingredients, source_items = {}, {}
for _, item in pairs(recipe.items or {}) do
if item and item ~= "" then ingredients[#ingredients + 1] = item_label(item) end
if item and item ~= "" then source_items[#source_items + 1] = item end
end
table.sort(ingredients)
local result = item_label(recipe.output or output_name)
local row = result .. " <- " .. (#ingredients > 0 and table.concat(ingredients, " + ") or "no ingredients")
local searchable = (row .. " " .. output_name .. " " .. table.concat(source_items, " ")):lower()
if needle == "" or searchable:find(needle, 1, true) then
rows[#rows + 1] = row
end
end
end
end
end
for _, note in ipairs(process_notes) do
local row = note[1] .. " / " .. note[2]
if needle == "" or row:lower():find(needle, 1, true) then rows[#rows + 1] = row end
end
table.sort(rows)
return rows
end
function hearth.recipes(player, query, page)
query = tostring(query or ""):sub(1, 48):gsub("[%c]", " ")
page = index(page, 1000)
local all = recipe_rows(query)
local page_size = 8
local pages_count = math.max(1, math.ceil(#all / page_size))
page = math.min(page, pages_count)
local lines = {}
for i = (page - 1) * page_size + 1, math.min(#all, page * page_size) do
lines[#lines + 1] = all[i]
end
if #lines == 0 then lines[1] = "No recipe or process matches that search." end
local name = player:get_player_name()
local session = hearth.sessions[name]
if not session then return end
session.recipe_query, session.recipe_page = query, page
C.show_formspec(name, "hearth:recipes", journal(14,10)
.. label(0.65,0.55,"eom.dev / THE LAST HEARTH",ink.mauve)
.. label(0.65,1.15,"RECIPE & PROCESS INDEX",ink.yellow) .. divider(0.65,1.65,12.7)
.. "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 .. "]"
.. 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 "")
.. (page < pages_count and "button[3.7,8.85;2.8,0.65;next;Next]" or "")
.. "button[10.05,8.85;3.3,0.65;close;Back to field notes]")
end
local function words(set)
local out = {}
for key, enabled in pairs(set or {}) do
if enabled then out[#out+1] = key:gsub("_", " ") end
end
table.sort(out)
return #out > 0 and table.concat(out, ", ") or "not specified"
end
local function season_words(set)
local out = {}
for _, season in ipairs({"Spring", "Summer", "Autumn", "Winter"}) do
if set and set[season] then out[#out+1] = season end
end
return #out > 0 and table.concat(out, ", ") or "not specified"
end
local role_names = {browse="wildlife browse", pollinator="flower-season pollinator support",
nitrogenfixer="bounded nitrogen-fixer policy", ectomycorrhiza="host-linked root exchange",
saprotroph="finite-substrate decomposition", nursery="habitat shelter",
engineer="habitat engineering (descriptive tag)", resin="resin-bearing (descriptor; no extraction here)",
parasite="parasitism (descriptor only; no plant damage)", allelopathy="chemical competition (descriptor only; no killing)"}
local function botanical_row(r)
local niche, roles = r.niche or {}, {}
for tag, enabled in pairs(r.tags or {}) do if enabled then roles[#roles+1] = role_names[tag] or tag end end
table.sort(roles)
local group_name = r.group
for _, g in ipairs(botanical_groups) do if g[1] == r.group then group_name = g[2] end end
local function range(value, scale)
return value and tostring(value[1]*(scale or 1)) .. "-" .. tostring(value[2]*(scale or 1)) or "unspecified"
end
return r.common .. " (" .. r.latin .. ") / " .. group_name .. " / " .. r.id
.. "\nHabitat: " .. words(niche.substrates) .. "; " .. (niche.aquatic or "land")
.. "; light " .. range(niche.light) .. "/15; moisture " .. range(niche.moisture,100)
.. "%; pH " .. range(niche.ph) .. (niche.hosts and "; hosts: " .. words(niche.hosts) or "")
.. (niche.nutrients and "; nutrient minimum " .. niche.nutrients or "")
.. (niche.aquatic ~= "land" and niche.water and "; water depth " .. range(niche.water) or "")
.. "\nActive seasons: " .. season_words(r.seasons)
.. (r.flowering and "; flowers: " .. season_words(r.flowering) or "")
.. (r.fruiting and "; fruit: " .. season_words(r.fruiting) or "")
.. "; toxic flag: " .. (r.toxic and "YES (catalogue label)" or "not flagged (NOT food permission)")
.. (r.food and "; seasonal game berry harvest" or "; no catalogue food harvest")
.. "\nRoles / conditional policy or descriptors: " .. table.concat(roles, ", ")
end
function hearth.botanical_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 all, ids, records, needle = {}, {}, catalogue(), query:lower()
for _, r in ipairs(records) do
local row = botanical_row(r)
if needle == "" or (row .. " " .. r.group .. " " .. words(r.tags)):lower():find(needle,1,true) then
all[#all+1], ids[#ids+1] = row, r.id
end
end
local count = math.max(1,math.ceil(#all/10))
page = index(page,count)
local lines, choices = {}, {}
session.botany_ids, session.botany_selected = {}, 1
for i=(page-1)*10+1,math.min(#all,page*10) do
lines[#lines+1] = all[i]
session.botany_ids[#choices+1] = ids[i]
choices[#choices+1] = esc(ids[i])
end
if #lines == 0 then lines[1] = #records == 0 and "Botanical catalogue unavailable in this runtime."
or "No species matches that literal search." end
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)
.. 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]"
.. label(0.7,2.95,"Page " .. page .. " / " .. count .. " / " .. #all .. " matches / up to 10 species per page",ink.muted)
.. "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 "")
.. (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
function hearth.inventory(player)
player:set_inventory_formspec("formspec_version[6]size[12.4,11.3]bgcolor[" .. bg .. ";true]box[0,0;12.4,11.3;#1e1e2eff]"
.. "label[0.5,0.45;eom.dev / THE LAST HEARTH]label[0.5,1.05;Prepare something worth carrying.]"
.. "listcolors[#313244;#45475a;#cdd6f4;#181825;#cdd6f4]"
player:set_inventory_formspec(journal(12.4,11.3)
.. label(0.6,0.45,"eom.dev / THE LAST HEARTH",ink.mauve)
.. label(0.6,1.05,"Prepare something worth carrying.",ink.muted) .. divider(0.6,1.45,11.2)
.. "list[current_player;craft;0.6,1.8;3,3;]list[current_player;craftpreview;4.8,2.9;1,1;]"
.. "label[4.9,2.2;RESULT]label[6.5,1.8;GATHER / BUILD / ENDURE]"
.. "textarea[6.5,2.4;5.4,2.6;;;Collect flint and sticks first.\nA roof keeps rain off.\nA hearth makes shelter a home.]"
.. 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 .. "]"
.. "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.]"
.. 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
function hearth.location_form(player, error_text)
C.show_formspec(player:get_player_name(), "hearth:location",
"formspec_version[6]size[10.8,7.3]bgcolor[" .. bg .. ";true]box[0,0;10.8,7.3;#1e1e2eff]"
.. "label[0.65,0.6;THE LAST HEARTH / SET YOUR WORLD'S LOCATION]"
.. "textarea[0.65,1.2;9.5,1.7;;;" .. esc("Choose approximate coordinates near you. No location is collected automatically.\nThey stay in this world; no internet lookup is performed.\nExample only: 45 latitude, 0 longitude.") .. "]"
.. "field[0.65,3.2;4.4,0.8;latitude;Latitude (-90 to 90);]"
.. "field[5.6,3.2;4.4,0.8;longitude;Longitude (-180 to 180);]"
.. "label[0.65,4.5;" .. esc(error_text or "No location chosen: neutral equatorial preview until configured.") .. "]"
.. "button[0.65,5.3;4.4,0.75;save_location;Use this location]"
.. "button_exit[5.6,5.3;4.4,0.75;later;Choose later]")
journal(10.8,7.3) .. label(0.65,0.55,"eom.dev / WORLD REFERENCE",ink.mauve)
.. label(0.65,1.15,"A PLACE UNDER THE SAME SKY",ink.yellow) .. divider(0.65,1.65,9.5)
.. "textarea[0.65,1.95;9.5,1.5;;;" .. esc("Choose approximate coordinates, never your address. Nothing is collected automatically.\nThey stay in this world; no internet lookup is performed.\nExample only: 45 latitude, 0 longitude.") .. "]"
.. "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 .. "]"
.. "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]"
.. "button_exit[5.6,6;4.4,0.7;later;Choose later]")
end
-- Observational only: one bounded habitat stencil, one in-memory fauna snapshot.
-- Do not call step/forage/deposit, scan entities, emerge terrain or write metadata.
function hearth.ecosystem(player)
if not hearth.sessions[player:get_player_name()] then return end
local succession, wildlife = hearth.succession, hearth.wildlife
local h = succession and succession.habitat and succession.habitat(player:get_pos()) or nil
local w = wildlife and wildlife.status and wildlife.status() or nil
local function number(value)
value = tonumber(value)
if not value or value ~= value or math.abs(value) == math.huge then return 0 end
return math.max(0, math.min(1000000, value))
end
local plants = h and string.format("Natural grass cover: %d Flower-support cover: %d\nShrub / fern / aquatic / tree cover: %d Nearby water cells: %d\nCanopy influence: %.1f Estimated light: %.1f / 15 Moisture: %.0f%%",
number(h.grass),number(h.flowers),number(h.cover),number(h.near_water or h.water),number(h.canopy),number(h.light),number(h.moisture)*100)
or "Succession observations are not available in this runtime."
if h then
-- The habitat API returns the actual support kind, not a guessed biome.
local ground = type(h.ground) == "string" and h.ground:sub(1,80) or nil
local ground_name = ground and item_label(ground:find(":",1,true) and ground or "hearth:" .. ground)
plants = "Ground: " .. (ground_name or "unknown") .. "\n" .. plants
.. "\nPollinator-support plants: " .. (h.pollination ~= nil and tostring(number(h.pollination)) or "unavailable")
.. " Nutrient reserve: " .. (h.nutrients ~= nil and tostring(number(h.nutrients)) .. " / 8" or "unavailable")
.. "\nShade: pine and spruce stay evergreen; deciduous canopy thins in Winter."
local function optional(value) return value ~= nil and tostring(number(value)) or "unavailable" end
plants = plants .. "\nGame substrate pH: " .. optional(h.ph)
.. " Nursery-support plants: " .. optional(h.nursery)
.. "\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."
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)
.. label(0.7,2.15,"LOCAL PATCH / 5 x 5 x 5 cells at your feet",ink.teal)
.. "textarea[0.65,2.55;12.7,2.2;;;" .. esc(plants) .. "]"
.. 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.") .. "]"
.. "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 .. "]"
.. "button[3.7,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
C.register_on_player_receive_fields(function(player, form, fields)
if form == "hearth:guide" then
if form == "hearth:species" then
reader.handle(player,fields)
return true
elseif form == "hearth:botany" then
local session = hearth.sessions[player:get_player_name()]
if not session then return true end
if fields.species_choice ~= nil then
local value = fields.species_choice
local n = type(value)=="string" and #value<=2 and value:match("^%d+$") and tonumber(value)
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)
elseif fields.ecosystem_survey then hearth.ecosystem(player)
elseif fields.species_article then
local id = (session.botany_ids or {})[session.botany_selected or 1]
if id then reader.show(player,id) end
elseif fields.search or fields.key_enter then hearth.botanical_guide(player,fields.query,1)
elseif fields.next then hearth.botanical_guide(player,session.botany_query,(session.botany_page or 1)+1)
elseif fields.previous then hearth.botanical_guide(player,session.botany_query,(session.botany_page or 1)-1) end
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)
elseif fields.botany_index then hearth.botanical_guide(player,"",1)
elseif fields.refresh then hearth.ecosystem(player) end
return true
elseif form == "hearth:recipes" then
local session = hearth.sessions[player:get_player_name()]
if not session then return true end
if fields.close then hearth.guide(player, 2)
elseif fields.search or fields.key_enter then hearth.recipes(player, fields.query, 1)
elseif fields.next then hearth.recipes(player, session.recipe_query, (session.recipe_page or 1) + 1)
elseif fields.previous then hearth.recipes(player, session.recipe_query, (session.recipe_page or 1) - 1) end
return true
elseif form == "hearth:guide" then
if not hearth.sessions[player:get_player_name()] then return true end
for i = 1, #pages do if fields["page_" .. i] then hearth.guide(player, i); return true end end
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.inspect_target then
local ok, message = inspect(player, "")
if not ok and hearth.notify then hearth.notify(player,message) end
return true
end
elseif form == "hearth:location" and fields.save_location then
if not C.is_singleplayer() then return true end
local lat, lon = fields.latitude or "", fields.longitude or ""
if #lat > 16 or #lon > 16 or not hearth.model.location(lat, lon) then
if type(lat) ~= "string" or type(lon) ~= "string" or #lat > 16 or #lon > 16 or not hearth.model.location(lat, lon) then
hearth.location_form(player, "Enter valid finite coordinates within the ranges above.")
else
hearth.set_location(tonumber(lat), tonumber(lon))
hearth.guide(player)
end
return true
elseif form == "" and fields.character_creator then
if hearth.character then hearth.character.show(player) end
return true
elseif form == "" and fields.field_guide then hearth.guide(player); return true end
end)
C.register_chatcommand("guide", {description = "Open the Last Hearth field notes",
func = function(name) local p = C.get_player_by_name(name); if p then hearth.guide(p) end; return true end})
C.register_chatcommand("recipes", {params = "[search words]", description = "Search known crafting recipes and hearth processes",
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.recipes(player, param or "", 1)
return true
end})
C.register_chatcommand("ecosystem", {description = "Observe loaded natural habitat and active wildlife without changing the world",
func = function(name)
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.ecosystem(player)
return true
end})
C.register_chatcommand("botany", {params = "[search]", description = "Browse named botanical species and game-only ecological field notes",
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.botanical_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)
local player = C.get_player_by_name(name)
if not player then return false, "Join the world first." end
return inspect(player,param or "")
end})
C.register_chatcommand("location", {description = "Choose local-world reference coordinates",
func = function(name)
if not C.is_singleplayer() then return false, "Server coordinates are selected by its administrator." end
@@ -83,19 +479,42 @@ C.register_chatcommand("location", {description = "Choose local-world reference
function hearth.make_hud(player)
local s = hearth.sessions[player:get_player_name()]
s.hud = {}
s.hud.title = player:hud_add({type = "text", position = {x = 0.025, y = 0.04},
alignment = {x = 1, y = 0}, text = "THE LAST HEARTH / eom.dev", number = 0xe5c890, size = {x = 1, y = 1}})
s.hud.clock = player:hud_add({type = "text", position = {x = 0.025, y = 0.085},
alignment = {x = 1, y = 0}, text = "", number = 0xcdd6f4, size = {x = 1, y = 1}})
s.hud.needs = player:hud_add({type = "text", position = {x = 0.025, y = 0.14},
alignment = {x = 1, y = 0}, text = "", number = 0xcdd6f4, size = {x = 1, y = 1}})
local function panel(key, texture, x, y)
s.hud[key] = player:hud_add({type="image",position={x=0,y=0},offset={x=x,y=y},
alignment={x=1,y=1},scale={x=1,y=1},text=texture,z_index=-10})
end
local function note(key, value, x, y, color)
s.hud[key] = player:hud_add({type="text",position={x=0,y=0},offset={x=x,y=y},
alignment={x=1,y=0},text=value,number=color,size={x=1,y=1},z_index=1})
end
panel("journal_panel","[fill:424x240:" .. ink.mantle .. "e8",12,12)
panel("journal_accent","[fill:3x240:" .. ink.teal,12,12)
note("title","THE LAST HEARTH / eom.dev",28,34,0xcba6f7)
note("clock","",28,68,0xa6adc8)
-- Keep the established session keys; runtime may replace context/help text.
note("needs","CONDITION / reserves",28,108,0xf9e2af)
for i, need in ipairs({"warmth","water","food","stamina"}) do
local y = 136+(i-1)*28
note(need .. "_label","",28,y,0xcdd6f4)
panel(need .. "_track","[fill:220x6:" .. ink.border,196,y-3)
panel(need .. "_fill","[fill:220x6:" .. ink.teal,196,y-3)
end
s.hud.context_panel = player:hud_add({type="image",position={x=0.5,y=0.77},
alignment={x=0,y=0},text="[fill:680x36:" .. ink.mantle .. "dd",scale={x=1,y=1},z_index=-10})
s.hud.context = player:hud_add({type = "text", position = {x = 0.5, y = 0.77},
alignment = {x = 0, y = 0}, text = "", number = 0xe5c890, size = {x = 1, y = 1}})
s.hud.help = player:hud_add({type = "text", position = {x = 0.975, y = 0.04},
alignment = {x = -1, y = 0}, text = "I Craft & field notes | E Sprint | /claim Homestead",
alignment = {x = 0, y = 0}, text = "", number = 0xf9e2af, size = {x = 1, y = 1}})
s.hud.help = player:hud_add({type = "text", position = {x = 1, y = 0},offset={x=-20,y=34},
alignment = {x = -1, y = 0}, text = "I Field journal / E Sprint\n/claim Homestead / /ecosystem Survey",
number = 0xa6adc8, size = {x = 1, y = 1}})
player:hud_set_flags({minimap = false})
player:hud_set_hotbar_itemcount(10)
-- The authored PNG is ONE slot; [combine has no leading parenthesis.
if player.hud_set_hotbar_image then
local slots = {"[combine:320x32"}
for i=0,9 do slots[#slots+1] = ":" .. (i*32) .. ",0=hearth_hotbar.png" end
player:hud_set_hotbar_image(table.concat(slots))
end
if player.hud_set_hotbar_selected_image then player:hud_set_hotbar_selected_image("hearth_selected.png") end
end
function hearth.update_hud(player, env)
@@ -105,12 +524,17 @@ function hearth.update_hud(player, env)
local clock = hearth.clock
local hour = math.floor(clock.hour)
player:hud_change(s.hud.clock, "text", clock.season .. " / " .. clock.date
.. string.format(" / Solar %02d:%02d / %d C%s", hour, math.floor(clock.hour % 1 * 60),
math.floor(env.temperature), hearth.has_location and "" or " [LOCATION NOT SET]"))
local function bar(n) return string.rep("|", math.floor(n / 10)) .. string.rep(".", 10 - math.floor(n / 10)) end
player:hud_change(s.hud.needs, "text", "WARMTH " .. bar(state.warmth)
.. "\nWATER " .. bar(state.water) .. "\nFOOD " .. bar(state.food)
.. "\nSTAMINA " .. bar(state.stamina))
.. string.format("\nSolar %02d:%02d / %d C%s", hour, math.floor(clock.hour % 1 * 60),
math.floor(env.temperature), hearth.has_location and "" or " / location unset"))
for _, need in ipairs({"warmth","water","food","stamina"}) do
local n = tonumber(state[need]) or 0
if n ~= n then n = 0 end
n = math.max(0,math.min(100,n))
local color = n < 20 and ink.danger or (n < 40 and ink.yellow or ink.teal)
player:hud_change(s.hud[need .. "_label"],"text",string.upper(need) .. " " .. math.floor(n) .. "%")
player:hud_change(s.hud[need .. "_label"],"number",tonumber(color:sub(2),16))
player:hud_change(s.hud[need .. "_fill"],"text","[fill:" .. math.max(1,math.floor(n*2.2)) .. "x6:" .. (n==0 and ink.mantle .. "00" or color))
end
local context = env.shelter >= 0.6 and "SHELTERED" or (env.roof and "ROOFED / add walls" or "EXPOSED / find cover")
context = context .. (env.rain and " | RAIN" or " | CLEAR")
if state.wet > 25 then context = context .. " | WET CLOTHING" end
@@ -118,5 +542,5 @@ function hearth.update_hud(player, env)
if env.smoke then context = "SMOKE / open a wall or roof vent" end
if state.warmth < 20 then context = context .. " | COLD: shelter and fire needed" end
player:hud_change(s.hud.context, "text", context)
player:hud_change(s.hud.context, "number", (env.smoke or state.warmth < 20) and 0xf38ba8 or 0xe5c890)
player:hud_change(s.hud.context, "number", (env.smoke or state.warmth < 20) and 0xf38ba8 or 0xf9e2af)
end
+5 -1
View File
@@ -55,8 +55,12 @@ function M.needs(state, env, dt)
local s = {}
for k, v in pairs(state) do s[k] = v end
local shelter = M.clamp(env.shelter or 0, 0, 1)
s.wet = M.clamp(s.wet + ((env.rain and shelter < 0.6) and 1.2 or -0.35) * dt, 0, 100)
local exposed = env.rain and shelter < 0.6
local wet_rate = exposed and (env.clothing and 0.35 or 1.2)
or (env.drying and -1.5 or -0.35)
s.wet = M.clamp(s.wet + wet_rate * dt, 0, 100)
local feels = env.temperature - s.wet * 0.09 + (env.heat or 0) * 14 + shelter * 5
+ (env.clothing and 4 or 0)
local target = M.clamp(60 + feels * 1.5, 0, 100)
s.warmth = M.clamp(s.warmth + M.clamp(target - s.warmth, -0.16, 0.3) * dt, 0, 100)
s.food = M.clamp(s.food - (env.sprinting and 0.035 or 0.012) * dt, 0, 100)
+43
View File
@@ -0,0 +1,43 @@
-- Pure M1 controller. Only ephemeral session fields and state.stamina mutate.
-- No engine access: the runtime supplies weapon, velocity, ground and water.
local M = {}
function M.delta(dt)
if type(dt) ~= "number" or dt ~= dt or dt <= 0 then return 0 end
return math.min(dt, 0.1) -- never replay stalls as movement/exhaustion
end
function M.step(session, state, controls, dt, env)
controls, env = controls or {}, env or {}
dt = M.delta(dt)
local velocity = env.velocity or {x = 0, y = 0, z = 0}
local grounded = env.grounded == true and math.abs(velocity.y or 0) < 0.2
local swimming = env.swimming == true
local moving = (velocity.x or 0)^2 + (velocity.z or 0)^2 > 0.04
if state.stamina <= 8 and not session.exhausted then
session.exhausted = true; session.sprint_released = false
end
if session.exhausted and not controls.aux1 then session.sprint_released = true end
if session.exhausted and session.sprint_released and state.stamina >= 30 then
session.exhausted, session.sprint_released = false, false
end
local blocking = env.weapon == "hearth:spear" and controls.RMB == true and not swimming
local sprinting = controls.aux1 == true and controls.up == true and not controls.down
and not controls.sneak and not blocking and grounded and not swimming
and not session.exhausted and state.stamina > 8
-- Keep survival rates identical to model.needs; runtime restores its duplicate
-- stamina result but retains that model's sprint hunger/thirst expenditure.
local rate = sprinting and -13 or ((state.food > 0 and state.water > 0) and 9 or 2)
state.stamina = math.max(0, math.min(100, state.stamina + rate * dt))
if sprinting and state.stamina <= 8 then
session.exhausted, session.sprint_released, sprinting = true, false, false
end
local target = swimming and 0.75 or (blocking and 0.6 or (sprinting and 1.45 or 1))
if not swimming and not blocking and (state.wet or 0) >= 60 then target = target * 0.95 end
local speed = session.movement_speed or 1
local change = math.max(-3 * dt, math.min(3 * dt, target - speed))
speed = speed + change
session.movement_speed, session.sprinting, session.blocking = speed, sprinting, blocking
session.grounded, session.swimming, session.moving = grounded, swimming, moving
return {speed = speed, sprinting = sprinting, blocking = blocking,
grounded = grounded, swimming = swimming, moving = moving}
end
return M
+126
View File
@@ -0,0 +1,126 @@
-- Engine adapter. dofile returns factory(schema, core, mod_storage).
-- Callers must fail startup on world errors and catch/kick on player load errors.
-- No logging of JSON, player identities, parser errors, or location data.
return function(schema, core, storage)
local P = {}
local world_kinds = {claims = true, homes = true, access = true, location = true, spawn = true}
local blocked = {}
local player_stores = setmetatable({}, {__mode = "k"})
local function fail()
error("Last Hearth: persistence failed; save retained for recovery", 0)
end
local function call(fn, ...)
local ok, value, second = pcall(fn, ...)
if not ok then fail() end
return value, second
end
local function read(store, key)
if blocked[store] and blocked[store][key] then fail() end
local raw = call(store.get_string, store, key)
if type(raw) ~= "string" then fail() end
return raw
end
local function decode(kind, raw)
local value = call(core.parse_json, raw, nil, true)
if type(value) ~= "table" then fail() end
return call(schema.decode, kind, value)
end
local function equal(a, b)
if type(a) ~= type(b) then return false end
if type(a) ~= "table" then return a == b end
for k, v in pairs(a) do if not equal(v, b[k]) then return false end end
for k in pairs(b) do if a[k] == nil then return false end end
return true
end
local function encode(kind, envelope)
local raw = call(core.write_json, envelope)
if type(raw) ~= "string" or raw == "" then fail() end
-- Validate the actual serialized result before touching either key.
local roundtrip, migrated = decode(kind, raw)
if migrated ~= false or not equal(envelope, roundtrip) then fail() end
return raw
end
local function backup(store, key, original)
-- Nonempty backup bytes are immutable, even if they are not valid JSON.
if read(store, key) ~= "" then return end
local result = call(store.set_string, store, key, original)
if result == false or read(store, key) ~= original then fail() end
end
local function commit(store, key, original, raw)
local ok = pcall(function()
local result = call(store.set_string, store, key, raw)
if result == false or read(store, key) ~= raw then fail() end
end)
if ok then return end
-- Engine metadata offers no transaction/CAS or crash-durability guarantee.
-- Best-effort byte-exact rollback must itself be verified. On failure the
-- key is quarantined for this adapter's lifetime; caller must stop/kick.
local restored = pcall(function()
if read(store, key) ~= original then
local result = call(store.set_string, store, key, original)
if result == false then fail() end
end
if read(store, key) ~= original then fail() end
end)
if not restored then
blocked[store] = blocked[store] or {}
blocked[store][key] = true
end
fail()
end
local function load(store, key, backup_key, kind)
local original = read(store, key)
if original == "" then
if kind == "needs" then return call(schema.defaults) end
if kind == "claims" or kind == "homes" or kind == "access" then return {} end
return nil
end
local envelope, migrated = decode(kind, original)
if migrated then
local raw = encode(kind, envelope)
backup(store, backup_key, original)
commit(store, key, original, raw)
end
return envelope.data
end
local function save(store, key, backup_key, kind, data)
local envelope = call(schema.decode, kind, {schema = 1, kind = kind, data = data})
local raw = encode(kind, envelope)
local original = read(store, key)
local migrated = false
if original ~= "" then
local previous, legacy = decode(kind, original)
if not previous then fail() end
migrated = legacy
end
if migrated then backup(store, backup_key, original) end
commit(store, key, original, raw)
end
local function world_kind(kind)
if type(kind) ~= "string" or not world_kinds[kind] then fail() end
end
local function metadata(player)
-- get_meta() may return a fresh userdata wrapper. Keep the same reference
-- for a player's session so failed-rollback quarantine cannot be bypassed.
if player_stores[player] then return player_stores[player] end
local meta = call(player.get_meta, player)
if meta == nil then fail() end
player_stores[player] = meta
return meta
end
function P.load_world(kind)
world_kind(kind)
return load(storage, kind, "m0_backup:" .. kind, kind)
end
function P.save_world(kind, data)
world_kind(kind)
save(storage, kind, "m0_backup:" .. kind, kind, data)
end
function P.load_player(player)
return load(metadata(player), "hearth:needs", "hearth:m0_backup:needs", "needs")
end
function P.save_player(player, needs)
save(metadata(player), "hearth:needs", "hearth:m0_backup:needs", "needs", needs)
end
return P
end
+377
View File
@@ -0,0 +1,377 @@
local C, P = core, hearth.persistence
local map_limit = tonumber(C.settings and C.settings:get("mapgen_limit")) or 512
local max_coord = math.min(31000, map_limit + 32)
local spawn_radius = 64
-- Two clear nodes above the highest surface stay inside the shared emerge box.
local MAX_SURFACE_Y = 318
local spawn = P.load_world("spawn")
local waiting, emerging = {}, {}
local function stop_player(player, operation)
local name = player:get_player_name()
hearth.players[name], hearth.sessions[name], waiting[name] = nil, nil, nil
C.log("error", "[hearth] Player " .. operation .. " failed; save retained for recovery.")
C.kick_player(name, "Last Hearth could not " .. operation .. " your data. Please contact the administrator for recovery.")
end
function hearth.player_save(player)
local state = hearth.players[player:get_player_name()]
if not state then return false end
local ok, result = pcall(P.save_player, player, state)
if not ok or result == false then stop_player(player, "save"); return false end
return true
end
local function node_at(pos)
local node = C.get_node_or_nil and C.get_node_or_nil(pos) or nil
if not node or node.name == "ignore" then return nil, true end
local def = C.registered_nodes[node.name]
if not def then return nil, false end
return def, false
end
local function liquid(def)
local groups = def.groups or {}
return (def.liquidtype and def.liquidtype ~= "none")
or (groups.liquid or 0) > 0 or (groups.water or 0) > 0 or (groups.lava or 0) > 0
end
local function clear_node(pos)
local def, unloaded = node_at(pos)
if unloaded then return false, true end
if not def or def.walkable ~= false or liquid(def) or (def.damage_per_second or 0) > 0 then return false, false end
return true, false
end
local function safe_exact(pos, name, avoid)
if type(pos) ~= "table" or type(pos.x) ~= "number" or type(pos.y) ~= "number" or type(pos.z) ~= "number"
or math.abs(pos.x) > max_coord or math.abs(pos.y) > max_coord or math.abs(pos.z) > max_coord then
return false, false
end
local foot = {x = math.floor(pos.x + 0.5), y = math.floor(pos.y + 0.5), z = math.floor(pos.z + 0.5)}
local support, support_unloaded = node_at({x = foot.x, y = foot.y - 1, z = foot.z})
local feet_ok, feet_unloaded = clear_node(foot)
local head_ok, head_unloaded = clear_node({x = foot.x, y = foot.y + 1, z = foot.z})
if not support or support.walkable ~= true or liquid(support) or (support.damage_per_second or 0) > 0
or not feet_ok or not head_ok then
return false, support_unloaded or feet_unloaded or head_unloaded
end
local candidate = {x = foot.x, y = foot.y, z = foot.z}
if name and hearth.claim_allowed and not hearth.claim_allowed(candidate, name, "arrival") then return false, false end
if avoid then
for _, center in ipairs(avoid) do
if center and math.abs(candidate.x - center.x) <= 12 and math.abs(candidate.y - center.y) <= 8
and math.abs(candidate.z - center.z) <= 12 then return false, false end
end
end
for _, other in ipairs(C.get_connected_players()) do
if other:get_player_name() ~= name then
local p = other:get_pos()
if math.abs(candidate.x - p.x) < 1 and math.abs(candidate.y - p.y) < 2
and math.abs(candidate.z - p.z) < 1 then return false, false end
end
end
if C.get_objects_inside_radius then
local self_object = name and C.get_player_by_name(name)
for _, object in ipairs(C.get_objects_inside_radius(candidate, 1.5)) do
if object ~= self_object then return false, false end
end
end
return candidate, false
end
local function natural_surface(def)
if not def or def.walkable ~= true or liquid(def) or (def.damage_per_second or 0) > 0 then return false end
local groups = def.groups or {}
for _, group in ipairs({"tree", "leaves", "wood", "hearth_canopy", "hearth_building", "hearth_non_support", "flora", "hearth_ecosystem"}) do
if (groups[group] or 0) > 0 then return false end
end
-- Includes biome soils/geology and loose snow, scree, gravel and clay.
return (groups.soil or 0) > 0 or (groups.stone or 0) > 0
or (groups.sand or 0) > 0 or (groups.crumbly or 0) > 0
end
local function safe_surface(pos, name, avoid)
local safe, missing = safe_exact(pos, name, avoid)
if not safe then return nil, missing end
-- A small dry landing patch, not a trunk, canopy, pillar or cliff edge.
for dx = -1, 1 do
for dz = -1, 1 do
local x, z = safe.x + dx, safe.z + dz
local support = node_at({x = x, y = safe.y - 1, z = z})
if not natural_surface(support) then
local clear = clear_node({x = x, y = safe.y - 1, z = z})
if not clear or not natural_surface(node_at({x = x, y = safe.y - 2, z = z})) then return nil, false end
end
if not clear_node({x = x, y = safe.y, z = z})
or not clear_node({x = x, y = safe.y + 1, z = z}) then return nil, false end
end
end
return safe, false
end
local function offsets(radius)
local result = {{0, 0}}
for distance = 1, radius do
for dx = -distance, distance do
local dz = distance - math.abs(dx)
result[#result + 1] = {dx, dz}
if dz ~= 0 then result[#result + 1] = {dx, -dz} end
end
end
return result
end
local horizontal_offsets = offsets(5)
local vertical_offsets = {0, 1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 6, -6}
local function nearby_safe(desired, name, avoid, surface)
local x, y, z = math.floor(desired.x + 0.5), math.floor(desired.y + 0.5), math.floor(desired.z + 0.5)
local unloaded = false
for _, delta in ipairs(vertical_offsets) do
for _, offset in ipairs(horizontal_offsets) do
local check = surface and safe_surface or safe_exact
local pos, missing = check({x = x + offset[1], y = y + delta, z = z + offset[2]}, name, avoid)
if pos then return pos, false end
unloaded = unloaded or missing
end
end
return nil, unloaded
end
local function set_waiting(name, value)
local session = hearth.sessions[name]
if session then session.waiting = value end
end
local function connected_waiter(name, entry)
local player = C.get_player_by_name(name)
return player and hearth.sessions[name] == entry.session and player == entry.player and player
end
local function finish_waiter(name, entry, position, reason)
if waiting[name] ~= entry then return end
local player = connected_waiter(name, entry)
waiting[name] = nil
if not player then return end
if not position then
set_waiting(name, true)
if entry.message then hearth.notify(player, reason or "No safe spawn found. Please choose another world seed.") end
return
end
local verified = safe_exact(position, name, entry.avoid)
if not verified then
set_waiting(name, true)
if entry.message then hearth.notify(player, "That arrival became unsafe; try /home again.") end
return
end
local ok = pcall(function()
player:set_pos(verified)
player:set_velocity({x = 0, y = 0, z = 0})
if entry.initial then player:get_meta():set_int("hearth:arrived", 1) end
end)
if not ok then set_waiting(name, true); return end
set_waiting(name, false)
entry.session.recovery_avoid = {x = verified.x, y = verified.y, z = verified.z,
until_us = C.get_us_time() + 30000000}
if entry.message then hearth.notify(player, entry.success or "You arrived at a verified safe location.") end
end
local function save_spawn(position)
if not spawn or spawn.x ~= position.x or spawn.y ~= position.y or spawn.z ~= position.z then
local ok = pcall(P.save_world, "spawn", position)
if not ok then return false end
spawn = {x = position.x, y = position.y, z = position.z}
end
return true
end
local function bounded_spawn(name, avoid)
local candidates = {}
for x = -spawn_radius, spawn_radius, 4 do
for z = -spawn_radius, spawn_radius, 4 do
for ground_y = MAX_SURFACE_Y, 0, -1 do
local def, missing = node_at({x = x, y = ground_y, z = z})
if missing or not def then break end
local groups = def.groups or {}
if natural_surface(def) then
local safe
if math.abs(x) < spawn_radius and math.abs(z) < spawn_radius then
safe = safe_surface({x = x, y = ground_y + 1, z = z}, name, avoid)
end
if safe then candidates[#candidates + 1] = safe end
-- Never descend through terrain to an arbitrary cave floor.
break
elseif liquid(def) or (def.damage_per_second or 0) > 0
or (def.walkable ~= false and (groups.tree or 0) <= 0
and (groups.leaves or 0) <= 0 and (groups.hearth_canopy or 0) <= 0) then break end
end
end
end
if #candidates == 0 then return nil end
return candidates[(math.floor(C.get_us_time() / 1000) % #candidates) + 1]
end
local process_all
local function mark_emerged(key)
for _, entry in pairs(waiting) do
if entry.emerged[key] == "pending" then entry.emerged[key] = "done" end
end
end
local function emerge_once(key, minimum, maximum)
if emerging[key] then return end
emerging[key] = true
C.emerge_area(minimum, maximum, function(_, action, remaining)
if action == C.EMERGE_ERRORED or action == C.EMERGE_CANCELLED then remaining = 0 end
if remaining ~= 0 then return end
emerging[key] = nil
mark_emerged(key)
C.after(0, process_all)
end)
end
local function target_key(pos)
return math.floor(pos.x + 0.5) .. ":" .. math.floor(pos.y + 0.5) .. ":" .. math.floor(pos.z + 0.5)
end
local function schedule_process(name, entry)
if entry.scheduled then return end
entry.scheduled = true
C.after(0, function()
if waiting[name] == entry then entry.scheduled = false; process_all() end
end)
end
process_all = function()
for name, entry in pairs(waiting) do
if not connected_waiter(name, entry) then
waiting[name] = nil
else
local target = entry.targets[entry.index]
if target then
local resolved, missing
if target.kind == "rescan" then
-- Probe the entire bounded volume, not just the old low-altitude hint.
missing = entry.emerged["bounded-spawn-rescan"] ~= "done"
else resolved, missing = nearby_safe(target.pos, name, entry.avoid, target.kind == "spawn") end
if resolved then
if (target.kind == "spawn" or target.kind == "rescan") and not save_spawn(resolved) then
finish_waiter(name, entry, nil, "Safe arrival storage failed; your data was retained.")
else finish_waiter(name, entry, resolved) end
elseif target.kind == "rescan" then
local key = "bounded-spawn-rescan"
if missing and entry.emerged[key] == "pending" then
-- A shared emergence is already servicing this waiter.
elseif missing and entry.emerged[key] ~= "done" then
entry.emerged[key] = "pending"
emerge_once(key, {x = -spawn_radius, y = 0, z = -spawn_radius},
{x = spawn_radius, y = MAX_SURFACE_Y + 2, z = spawn_radius})
else
local candidate = bounded_spawn(name, entry.avoid)
if candidate and save_spawn(candidate) then finish_waiter(name, entry, candidate)
elseif candidate then finish_waiter(name, entry, nil, "Safe arrival storage failed; your data was retained.")
else finish_waiter(name, entry, nil, "No safe arrival was found in the bounded spawn area. Stay safe and try /home again.") end
end
elseif missing then
local key = target_key(target.pos)
if entry.emerged[key] == "done" then
entry.index = entry.index + 1
schedule_process(name, entry)
elseif not entry.emerged[key] then
entry.emerged[key] = "pending"
emerge_once(key,
{x = target.pos.x - 8, y = target.pos.y - 8, z = target.pos.z - 8},
{x = target.pos.x + 8, y = target.pos.y + 8, z = target.pos.z + 8})
end
elseif target.kind ~= "rescan" then
entry.index = entry.index + 1
schedule_process(name, entry)
end
else
finish_waiter(name, entry, nil)
end
end
end
end
local function begin_recovery(player, initial, message, prefer_home, avoid_current)
local name = player:get_player_name()
local session = hearth.sessions[name]
if not session then return false end
local now, avoid = C.get_us_time(), {}
if avoid_current then
local current = player:get_pos()
avoid[#avoid + 1] = {x = current.x, y = current.y, z = current.z}
end
if avoid_current and session.recovery_avoid and now < session.recovery_avoid.until_us then
avoid[#avoid + 1] = session.recovery_avoid
end
local targets = {}
if prefer_home then
local home = hearth.home_for(name)
if home then targets[#targets + 1] = {kind = "home", pos = home} end
end
if spawn then targets[#targets + 1] = {kind = "spawn", pos = {x = spawn.x, y = spawn.y, z = spawn.z}} end
targets[#targets + 1] = {kind = "rescan", pos = {x = 0, y = 32, z = 0}}
local entry = {player = player, session = session, initial = initial, message = message,
success = "You arrived at a verified safe location.", avoid = avoid,
targets = targets, index = 1, emerged = {}}
waiting[name] = entry
set_waiting(name, true)
process_all()
return true
end
function hearth.safe_arrival(player)
return begin_recovery(player, false, false, true, true)
end
function hearth.set_home(player)
local name = player:get_player_name()
local session = hearth.sessions[name]
if not session or session.waiting then return false, "Wait until your arrival is safe before setting a home." end
local pos = player:get_pos()
local point = {x = math.floor(pos.x + 0.5), y = math.floor(pos.y + 0.5), z = math.floor(pos.z + 0.5)}
if not hearth.in_own_claim(point, name) then return false, "Stand inside your own homestead claim before setting a home." end
local safe = safe_exact(point, name)
if not safe then return false, "Home must be dry, grounded, supported, and clear above your claim." end
hearth.store_home(name, safe)
return true, "Safe home saved inside your homestead."
end
function hearth.go_home(player)
local name = player:get_player_name()
if not hearth.sessions[name] then return false, "Join the world first." end
begin_recovery(player, false, true, true, false)
if hearth.home_for(name) then return true, "Checking your home and safe fallback before moving you." end
return true, "No personal home is saved; checking world spawn and safe fallback."
end
function hearth.player_join(player)
local name = player:get_player_name()
local ok, saved = pcall(P.load_player, player)
if not ok or type(saved) ~= "table" then stop_player(player, "load"); return end
local meta = player:get_meta()
hearth.players[name], hearth.sessions[name] = saved, {page = 1, damage = 0, save = 0, ready = false}
local session = hearth.sessions[name]
local inv = player:get_inventory()
inv:set_size("main", 40); inv:set_size("craft", 9); inv:set_width("craft", 3)
inv:set_size("craftpreview", 1); inv:set_size("craftresult", 1)
player:set_properties({visual = "mesh", mesh = "hearth_person.b3d", textures = {"hearth_person.png"},
visual_size = {x = 1, y = 1}, collisionbox = {-0.3, 0, -0.3, 0.3, 1.75, 0.3},
stepheight = 0.6, eye_height = 1.55, hp_max = 20})
if hearth.character then hearth.character.on_join(player) end
player:set_armor_groups({fleshy = 100})
hearth.inventory(player); hearth.make_hud(player)
player:set_clouds({density = 0.35, color = "#bec4c8e0", height = 140, speed = {x = 2, z = -1}})
if meta:get_int("hearth:arrived") == 0 then begin_recovery(player, true, false, true, false) end
C.after(1, function()
local current = C.get_player_by_name(name)
if not current or hearth.sessions[name] ~= session then return end
if not hearth.has_location and C.is_singleplayer() then hearth.location_form(current)
else hearth.guide(current) end
end)
end
function hearth.player_leave(player)
hearth.player_save(player)
local name = player:get_player_name()
hearth.players[name], hearth.sessions[name], waiting[name] = nil, nil, nil
end
function hearth.player_respawn(player)
local name = player:get_player_name()
local state, session = hearth.players[name], hearth.sessions[name]
if not state or not session then return true end
state.stamina = 35
state.warmth = math.min(state.warmth, 60)
begin_recovery(player, false, false, true, true)
return true
end
C.register_on_joinplayer(hearth.player_join)
C.register_on_leaveplayer(hearth.player_leave)
C.register_on_shutdown(function() for _, player in ipairs(C.get_connected_players()) do hearth.player_save(player) end end)
C.register_on_respawnplayer(hearth.player_respawn)
C.register_on_punchplayer(function() return true end)
+114
View File
@@ -0,0 +1,114 @@
-- Ephemeral third-person visuals only. Main loads this once before runtime.
-- No ItemStacks, inventories, metadata, static data, attacks or file paths from input.
local C, H = core, hearth
local meshes = {
["hearth:knife"] = "hearth_knife.obj",
["hearth:axe"] = "hearth_axe.obj",
["hearth:pick"] = "hearth_pick.obj",
["hearth:spear"] = "hearth_spear.obj",
}
local records, pending = {}, nil
local entity_name = "hearth:held_visual"
local function live(record)
local p, name = record.player, record.name
return C.get_player_by_name(name) == p and H.sessions[name] == record.session
and H.players[name] ~= nil and not record.session.waiting
and p:get_pos() ~= nil and p:get_hp() > 0
and p:get_wielded_item():get_name() == record.item
end
local function remove(record)
-- Clear ownership BEFORE remove/on_deactivate; an old callback cannot clear
-- a replacement belonging to a same-name reconnect or a different weapon.
if records[record.name] == record then records[record.name] = nil end
local object = record.object
record.object = nil
if object and object:get_pos() then object:remove() end
end
local function attached(record)
local object = record.object
if not object or not object:get_pos() then return false end
local parent, bone, pos, rotation, forced = object:get_attach()
return parent == record.player and bone == "Hand_Right" and pos
and pos.x == 0 and pos.y == 0 and pos.z == 0 and rotation
and rotation.x == 90 and rotation.y == 0 and rotation.z == 0 and forced == false
end
C.register_entity(entity_name, {
initial_properties = {
visual = "mesh", mesh = "hearth_knife.obj", textures = {"hearth_person.png"},
-- The generated B3D and held OBJ both use model units (10 per node).
visual_size = {x = 1, y = 1, z = 1}, is_visible = false,
physical = false, collide_with_objects = false, pointable = false,
collisionbox = {0, 0, 0, 0, 0, 0}, selectionbox = {0, 0, 0, 0, 0, 0},
static_save = false, hp_max = 1, makes_footstep_sound = false,
},
on_activate = function(self, staticdata)
-- Only synchronous creation by presentation_step may bind an entity.
-- Restored/foreign/unowned entities disappear rather than becoming drops.
local record = pending
if staticdata ~= "" or not record or record.object or not live(record) then
self.object:remove(); return
end
record.object, self._held_record = self.object, record
self.object:set_armor_groups({immortal = 1})
end,
get_staticdata = function() return "" end,
on_step = function(self)
local record = self._held_record
if not record then self.object:remove(); return end
if records[record.name] ~= record or not live(record) or not attached(record) then remove(record) end
end,
on_deactivate = function(self)
local record = self._held_record
self._held_record = nil
if record then
if records[record.name] == record then records[record.name] = nil end
record.object = nil
end
end,
-- Cosmetic, non-pointable, immortal; even a direct punch has no gameplay effect.
on_punch = function() return true end,
on_rightclick = function() end,
})
function H.presentation_step(player)
local name = player:get_player_name()
local record, session = records[name], H.sessions[name]
local item = player:get_wielded_item():get_name()
local mesh = meshes[item]
if record then
if record.player == player and live(record) and attached(record) then return end
-- A deferred call on an old PlayerRef must not destroy the live replacement.
if record.player ~= player and C.get_player_by_name(name) ~= player then return end
remove(record)
end
if not mesh or not session then return end
record = {name = name, player = player, session = session, item = item}
if not live(record) then return end
records[name], pending = record, record
local ok, object = pcall(C.add_entity, player:get_pos(), entity_name)
pending = nil
if not ok or not object or object ~= record.object or not object:get_pos() then
remove(record); return
end
object:set_properties({mesh = mesh})
-- forced_visible is the FIFTH argument: false hides this from its owner in
-- first person, but permits third-person rendering. Never toggle is_visible
-- from a guessed server-side camera mode (it would hide it for observers).
object:set_attach(player, "Hand_Right", {x = 0, y = 0, z = 0}, {x = 90, y = 0, z = 0}, false)
if not attached(record) then remove(record); return end
object:set_properties({is_visible = true})
end
function H.presentation_cleanup(player)
local record = records[player:get_player_name()]
if record and record.player == player then remove(record) end
end
C.register_on_leaveplayer(H.presentation_cleanup)
C.register_on_dieplayer(H.presentation_cleanup)
C.register_on_shutdown(function()
local snapshot = {}; for _, record in pairs(records) do snapshot[#snapshot + 1] = record end
for _, record in ipairs(snapshot) do remove(record) end
end)
return H.presentation_step
+258
View File
@@ -0,0 +1,258 @@
-- Real-time hearth processes; construction supplies the shared access check.
return function(hearth, api, construction)
local allowed = construction.allowed
local MAX_FUEL = 600
-- Exact names only: building timber, charges and arbitrary tree groups are not fuel.
local FUEL = { ["hearth:sticks"] = 45, ["hearth:log"] = 180, ["hearth:charcoal"] = 90,
["hearth:coal"] = 240, ["hearth:oak_log"] = 180, ["hearth:birch_log"] = 180,
["hearth:pine_log"] = 180, ["hearth:willow_log"] = 180, ["hearth:alder_log"] = 180,
["hearth:beech_log"] = 180, ["hearth:aspen_log"] = 180, ["hearth:rowan_log"] = 180,
["hearth:hazel_log"] = 180, ["hearth:spruce_log"] = 180 }
local JOBS = {
["hearth:raw_food"] = {kind = "cook", duration = 6, cost = 20,
result = "hearth:cooked_food"},
["hearth:raw_fish"] = {kind = "roast", duration = 8, cost = 20,
result = "hearth:cooked_fish"},
["hearth:water_raw"] = {kind = "boil", duration = 4, cost = 10,
result = "hearth:water_clean"},
["hearth:smelt_charge"] = {kind = "smelt", duration = 60, cost = 30,
result = "hearth:iron_ingot"},
["hearth:copper_charge"] = {kind = "smelt_copper", duration = 45, cost = 30,
result = "hearth:copper_ingot"},
["hearth:dried_food_charge"] = {kind = "dry", duration = 300, cost = 30,
result = "hearth:dried_food"},
["hearth:clay_jar_charge"] = {kind = "fire", duration = 120, cost = 30,
result = "hearth:clay_jar"},
["hearth:charcoal_charge"] = {kind = "char", duration = 60, cost = 30,
result = "hearth:charcoal"},
["hearth:fish_drying_charge"] = {kind = "dry_fish", duration = 300, cost = 30,
result = "hearth:dried_fish"},
}
local RESULTS = { ["hearth:cooked_food"] = true, ["hearth:water_clean"] = true,
["hearth:iron_ingot"] = true, ["hearth:copper_ingot"] = true,
["hearth:dried_food"] = true, ["hearth:clay_jar"] = true,
["hearth:charcoal"] = true, ["hearth:cooked_fish"] = true, ["hearth:dried_fish"] = true }
local function is_fire(pos)
local name = api.get_node(pos).name
return name == "hearth:fire" or name == "hearth:fire_lit"
end
local function fuel_left(pos, now)
local deadline = tonumber(api.get_meta(pos):get_string("fuel_until")) or 0
if deadline ~= deadline or deadline == math.huge or deadline == -math.huge then return 0 end
return math.max(0, math.min(MAX_FUEL, deadline - now))
end
local function job_deadline(meta)
local raw = meta:get_string("job_until")
if raw == "" then return 0 end
local value = tonumber(raw)
if not value or value ~= value or value == math.huge or value == -math.huge then return nil end
return math.floor(value)
end
local function valid_job(meta)
local kind = meta:get_string("job_kind")
if kind == "" then return nil end
local until_time, cost = job_deadline(meta), meta:get_int("job_cost")
local result = meta:get_string("job_result")
if not until_time or until_time <= 0 then return nil end
for _, job in pairs(JOBS) do
if kind == job.kind and cost == job.cost and result == job.result then
return {kind = kind, until_time = until_time, cost = cost, result = result}
end
end
return nil
end
local function clear_job(meta)
meta:set_string("job_kind", "")
meta:set_string("job_until", "0")
meta:set_int("job_cost", 0)
end
local function pending_output(meta)
local result = meta:get_string("job_result")
if meta:get_string("job_kind") == "" and job_deadline(meta) == 0
and meta:get_int("job_cost") == 0 and RESULTS[result] then
return result
end
return ""
end
local function schedule_timer(pos, remaining, now)
local meta = api.get_meta(pos)
local wait
if remaining > 0 then wait = remaining end
local job = valid_job(meta)
if job then
local job_wait = job.until_time - now
if job_wait <= 0 then job_wait = 0.1 end
wait = wait and math.min(wait, job_wait) or job_wait
end
local timer = api.get_node_timer(pos)
if wait then
timer:start(math.max(0.1, wait))
else
timer:stop()
end
end
local function refresh_fire(pos, remaining, now)
local meta = api.get_meta(pos)
now = now or os.time()
remaining = remaining == nil and fuel_left(pos, now) or remaining
if remaining <= 0 then
remaining = 0
meta:set_string("fuel_until", "0")
meta:set_int("lit", 0)
end
local job = valid_job(meta)
local output = pending_output(meta)
local lit = meta:get_int("lit") == 1 and remaining > 0
meta:set_int("lit", lit and 1 or 0)
if output ~= "" and not job then
meta:set_string("infotext", "Last Hearth: finished " .. output .. " ready to collect")
elseif job then
local verb = ({cook = "cooking", roast = "roasting fish", boil = "boiling", smelt = "smelting iron",
smelt_copper = "smelting copper",
dry = "drying food", dry_fish = "drying fish", fire = "firing", char = "making charcoal"})[job.kind] or "processing"
meta:set_string("infotext", "Last Hearth: " .. verb .. "; " .. math.max(0,
math.ceil(job.until_time - now)) .. "s remaining")
elseif lit then
meta:set_string("infotext", "Last Hearth: lit; " .. math.ceil(remaining)
.. "s fuel; feed sticks/logs/charcoal/coal or process a charge")
elseif remaining > 0 then
meta:set_string("infotext", "Fueled but cold hearth: ignite with a firestarter")
else
meta:set_string("infotext", "Cold hearth: feed dry sticks or a log")
end
local node = api.get_node(pos)
local name = lit and "hearth:fire_lit" or "hearth:fire"
if node.name ~= name then
api.swap_node(pos, {name = name, param2 = node.param2})
end
schedule_timer(pos, remaining, now)
end
local function finish_job(_, meta, job)
clear_job(meta) -- Clear the reservation before committing its one output.
meta:set_string("job_result", job.result)
end
local function reconcile_job(pos, now)
local meta = api.get_meta(pos)
local kind = meta:get_string("job_kind")
if kind == "" then
local output = meta:get_string("job_result")
if (output ~= "" and pending_output(meta) == "")
or job_deadline(meta) ~= 0 or meta:get_int("job_cost") ~= 0 then
clear_job(meta)
meta:set_string("job_result", "")
end
return
end
local job = valid_job(meta)
if not job or job.until_time <= 0 then
clear_job(meta)
meta:set_string("job_result", "")
return
end
if job.until_time <= now then
finish_job(pos, meta, job)
end
end
local function fire_tick(pos)
if not is_fire(pos) then return false end
local now = os.time()
reconcile_job(pos, now)
refresh_fire(pos, fuel_left(pos, now), now)
return false -- refresh_fire schedules only the next job/fuel deadline.
end
local function use_fire(pos, _, player, stack)
if not is_fire(pos) or not allowed(pos, player, "use") then return stack end
local now = os.time()
local remaining = fuel_left(pos, now)
local meta = api.get_meta(pos)
if stack:is_empty() then
local result = pending_output(meta)
if result ~= "" then
local inv = player:get_inventory()
local output = ItemStack(result)
if inv and inv:room_for_item("main", output) then
local leftover = inv:add_item("main", output)
if leftover:is_empty() then meta:set_string("job_result", "") end
else
hearth.notify(player, "Make inventory space to collect the hearth output.")
end
end
refresh_fire(pos, remaining, now)
return stack
end
local item = stack:get_name()
local burn = FUEL[item]
if burn then
if remaining + burn <= MAX_FUEL then
stack:take_item(1)
remaining = remaining + burn
meta:set_string("fuel_until", tostring(now + remaining))
else
hearth.notify(player, "The hearth has no room for that much fuel yet.")
end
else
local job = JOBS[item]
if job then
if meta:get_string("job_kind") ~= "" or meta:get_string("job_result") ~= "" then
hearth.notify(player, "Collect the finished hearth output first.")
elseif meta:get_int("lit") ~= 1 then
hearth.notify(player, "Ignite the fueled hearth before cooking or boiling.")
elseif remaining < job.duration + job.cost then
hearth.notify(player, "Add more fuel to reserve the full hearth process.")
else
meta:set_string("job_kind", job.kind)
meta:set_string("job_until", tostring(now + job.duration))
meta:set_int("job_cost", job.cost)
meta:set_string("job_result", job.result)
-- Reserve heat cost at queue time; elapsed processing burns
-- naturally against the absolute UTC deadline. Paused servers do
-- not grant free fuel, but a completed offline job remains queued.
remaining = remaining - job.cost
meta:set_string("fuel_until", tostring(now + remaining))
-- Start the timer before consuming input; queued output is persistent.
schedule_timer(pos, remaining, now)
stack:take_item(1)
end
end
end
refresh_fire(pos, remaining, now)
return stack
end
local function ignite_fire(pos, player)
if not is_fire(pos) or not allowed(pos, player, "use") then return false end
local meta = api.get_meta(pos)
local now = os.time()
local remaining = fuel_left(pos, now)
if meta:get_int("lit") == 1 or remaining <= 0 then
refresh_fire(pos, remaining, now)
return false
end
meta:set_int("lit", 1)
refresh_fire(pos, remaining, now)
if api.sound_play then
api.sound_play("hearth_fire", {pos = pos, gain = 0.5, max_hear_distance = 16})
end
return true
end
local function use_firestarter(stack, player, pointed)
if pointed and pointed.type == "node" and ignite_fire(pointed.under, player) then
stack:add_wear(math.ceil(65535 / 20))
end
return stack
end
return {use_fire = use_fire, fire_tick = fire_tick,
refresh_fire = refresh_fire, fuel_left = fuel_left, ignite_fire = ignite_fire,
use_firestarter = use_firestarter}
end
+124 -179
View File
@@ -1,177 +1,110 @@
local C, M = core, hearth.model
local lat_setting, lon_setting = C.settings:get("hearth_latitude"), C.settings:get("hearth_longitude")
local configured = M.location(lat_setting, lon_setting)
local location_data = hearth.storage:get_string("location")
local stored = location_data ~= "" and C.parse_json(location_data) or nil
local selected = configured or (C.is_singleplayer() and stored and M.location(stored.latitude, stored.longitude))
if not C.is_singleplayer() and not configured then
error("Last Hearth server requires explicit hearth_latitude and hearth_longitude settings.")
end
hearth.has_location = selected ~= nil
local location = selected or {latitude = 0, longitude = 0}
function hearth.set_location(lat, lon)
assert(C.is_singleplayer(), "Only local worlds can select location in game")
local value = assert(M.location(lat, lon), "Invalid reference coordinates")
location, hearth.has_location = value, true
hearth.storage:set_string("location", C.write_json(value))
end
local function timestamp()
-- Date fixtures are only accepted for local play, never a public server.
if C.is_singleplayer() then
local fixture = tonumber(C.settings:get("hearth_preview_date"))
if fixture and fixture >= 946684800 and fixture <= 4102444800 then return fixture end
end
return os.time()
end
local function clock_update()
local now = timestamp()
hearth.clock = M.solar(now, location.latitude, location.longitude)
hearth.weather = M.weather(now, location.latitude, location.longitude)
if C.is_singleplayer() then
local weather = C.settings:get("hearth_preview_weather")
if weather == "rain" or weather == "clear" then hearth.weather = weather end
end
C.set_timeofday(hearth.clock.timeofday)
end
C.after(0, clock_update)
local function solid(pos)
local n = C.get_node_or_nil(pos)
if not n then return false end
local def = C.registered_nodes[n.name]
return def and def.walkable ~= false and not def.buildable_to
and C.get_item_group(n.name, "leaves") == 0
end
function hearth.environment(pos)
local head = vector.round({x = pos.x, y = pos.y + 1, z = pos.z})
local roof = false
for y = 1, 5 do if solid(vector.offset(head, 0, y, 0)) then roof = true; break end end
local walls = 0
for _, d in ipairs({{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) do
for r = 1, 3 do
if solid(vector.offset(head, d[1] * r, 0, d[2] * r)) then walls = walls + 1; break end
end
end
local shelter = roof and (0.25 + walls * 0.1875) or 0
local fire = C.find_node_near(pos, 4, {"hearth:fire_lit"})
local heat = 0
if fire then
local deadline = tonumber(C.get_meta(fire):get_string("fuel_until")) or 0
if M.fuel_remaining(deadline, os.time()) > 0 then
-- A wall between the hearth and player blocks its warmth.
local from = vector.offset(fire, 0, 0.6, 0)
if C.line_of_sight(from, head, 0.5) then heat = 1 - math.min(0.8, vector.distance(pos, fire) / 5) end
end
end
return {roof = roof, shelter = shelter, heat = heat, smoke = heat > 0 and roof and walls == 4,
rain = hearth.weather == "rain", temperature = hearth.clock.temperature - math.max(0, pos.y) * 0.0065}
end
local defaults = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100}
local function save(player)
local s = hearth.players[player:get_player_name()]
if s then player:get_meta():set_string("hearth:needs", C.write_json(s)) end
end
local spawn_data = hearth.storage:get_string("spawn")
local spawn = spawn_data ~= "" and C.parse_json(spawn_data) or nil
local spawn_pending = false
local waiting = {}
local function arrive(player)
if spawn then
player:set_pos(spawn)
player:set_velocity({x = 0, y = 0, z = 0})
end
end
local function locate_spawn(player)
if spawn then arrive(player); return end
waiting[player:get_player_name()] = true
if spawn_pending then return end
spawn_pending = true
C.emerge_area({x = -48, y = -16, z = -48}, {x = 48, y = 96, z = 48}, function(_, _, left)
if left ~= 0 then return end
local best, best_score
for x = -44, 44, 4 do for z = -44, 44, 4 do
for y = 78, 3, -1 do
local n = C.get_node({x = x, y = y, z = z})
if n.name == "hearth:grass" then
local p = {x = x, y = y + 1, z = z}
if C.get_node(p).name == "air" and C.get_node(vector.offset(p, 0, 1, 0)).name == "air" then
local score = math.abs(x) + math.abs(z) + y * 0.2
if not best_score or score < best_score then best, best_score = p, score end
end
break
end
end
end end
if not best then
C.log("error", "[hearth] No safe arrival in initial area; select a different world seed.")
for name in pairs(waiting) do C.kick_player(name, "No safe spawn found. Please choose another world seed.") end
waiting, spawn_pending = {}, false
return
end
spawn = best
hearth.storage:set_string("spawn", C.write_json(spawn))
for name in pairs(waiting) do local p = C.get_player_by_name(name); if p then arrive(p) end end
waiting = {}
end)
end
C.register_on_joinplayer(function(player)
local name, meta = player:get_player_name(), player:get_meta()
local saved_data = meta:get_string("hearth:needs")
local saved = saved_data ~= "" and C.parse_json(saved_data) or {}
local state = {}
for key, value in pairs(defaults) do state[key] = M.clamp(tonumber(saved[key]) or value, 0, 100) end
hearth.players[name], hearth.sessions[name] = state, {page = 1, damage = 0, save = 0, ready = false}
local inv = player:get_inventory()
inv:set_size("main", 40); inv:set_size("craft", 9); inv:set_width("craft", 3)
inv:set_size("craftpreview", 1); inv:set_size("craftresult", 1)
player:set_properties({visual = "mesh", mesh = "hearth_person.obj", textures = {hearth.texture("#947853")},
visual_size = {x = 1, y = 1}, collisionbox = {-0.3, 0, -0.3, 0.3, 1.75, 0.3},
stepheight = 0.6, eye_height = 1.55, hp_max = 20})
player:set_armor_groups({fleshy = 100})
hearth.inventory(player); hearth.make_hud(player)
player:set_clouds({density = 0.35, color = "#bec4c8e0", height = 140, speed = {x = 2, z = -1}})
if meta:get_int("hearth:arrived") == 0 then
meta:set_int("hearth:arrived", 1)
locate_spawn(player)
end
C.after(1, function()
local p = C.get_player_by_name(name)
if not p then return end
if not hearth.has_location and C.is_singleplayer() then hearth.location_form(p)
else hearth.guide(p) end
end)
end)
C.register_on_leaveplayer(function(player)
save(player)
local map_limit = tonumber(C.settings and C.settings:get("mapgen_limit")) or 512
local recovery_limit = map_limit + 32
-- Clock, environment, and lifecycle modules are installed before scheduling.
-- Main installs hearth.movement before this module; feedback is optional.
function hearth.movement_step(player, dt)
local name = player:get_player_name()
hearth.players[name], hearth.sessions[name], waiting[name] = nil, nil, nil
end)
C.register_on_shutdown(function() for _, p in ipairs(C.get_connected_players()) do save(p) end end)
C.register_on_respawnplayer(function(player)
local s = hearth.players[player:get_player_name()]
if s then for k, v in pairs(defaults) do s[k] = v end end
locate_spawn(player)
return true
end)
-- PvP is off regardless of a deployment accidentally changing enable_pvp.
C.register_on_punchplayer(function() return true end)
local s, state = hearth.sessions[name], hearth.players[name]
if not s or not state or s.waiting or player:get_hp() <= 0 then return end
local pos, velocity = player:get_pos(), player:get_velocity()
local function definition(y)
local node = C.get_node({x = pos.x, y = pos.y + y, z = pos.z})
return C.registered_nodes[node.name] or {}
end
local below, feet, body = definition(-0.1), definition(0.1), definition(0.9)
local function water(def) return def.groups and (def.groups.water or 0) > 0 end
local result = hearth.movement.step(s, state, player:get_player_control(), dt, {
weapon = player:get_wielded_item():get_name(), velocity = velocity,
grounded = below.walkable ~= false and next(below) ~= nil,
swimming = water(body) or (water(feet) and below.walkable == false),
})
-- Only override speed: leave other mods' jump/gravity and camera untouched.
if not s.speed or math.abs(result.speed - s.speed) >= 0.005 then
player:set_physics_override({speed = result.speed}); s.speed = result.speed
end
return result
end
local REST_SECONDS, REST_COOLDOWN = 5, 30
function hearth.start_rest(player, pos)
if not player or not player:is_player() then return false end
local name = player:get_player_name()
local session, state = hearth.sessions[name], hearth.players[name]
if not session or not state or session.waiting or player:get_hp() <= 0 then return false end
if not hearth.construction.allowed(pos, player, "use") then return false end
local node = C.get_node_or_nil(pos)
if not node or node.name ~= "hearth:bedroll" then return false end
local now = C.get_us_time() / 1000000
if now < (session.rest_ready_at or 0) then
hearth.feedback(player, "Rest again in " .. math.ceil(session.rest_ready_at - now) .. "s", "warning")
return false
end
if session.resting then hearth.feedback(player, "Already resting", "info"); return false end
if now < (session.attack_ready_at or 0) or now < (session.spar_ready_at or 0) then
hearth.feedback(player, "Recover from the encounter before resting", "warning")
return false
end
local p, velocity = player:get_pos(), player:get_velocity()
local cover = hearth.environment(p)
if math.abs(velocity.x) + math.abs(velocity.z) > 0.25 or cover.shelter < 0.6 or cover.smoke then
hearth.feedback(player, "Stay still on the bedroll in sheltered, ventilated space to rest", "warning")
return false
end
session.resting = {pos = {x = pos.x, y = pos.y, z = pos.z},
player_pos = {x = p.x, y = p.y, z = p.z}, hp = player:get_hp(), elapsed = 0}
hearth.feedback(player, "Resting / remain still under cover for five seconds", "info")
return true
end
function hearth.rest_step(player, dt, controls)
local name = player:get_player_name()
local session, state = hearth.sessions[name], hearth.players[name]
local rest = session and session.resting
if not rest then return false end
local p, velocity = player:get_pos(), player:get_velocity()
local moved = math.abs(p.x - rest.player_pos.x) + math.abs(p.y - rest.player_pos.y)
+ math.abs(p.z - rest.player_pos.z)
local now = C.get_us_time() / 1000000
local interrupted = not state or not controls or controls.up or controls.down
or controls.left or controls.right or controls.jump or controls.sneak
or moved > 0.2 or math.abs(velocity.x) + math.abs(velocity.z) > 0.25
or player:get_hp() < rest.hp
-- Shelter/claim checks are spatial queries; cache them briefly instead of
-- rescanning roof/fire geometry at the full render-step frequency.
if now >= (rest.validate_at or 0) then
local bed = C.get_node_or_nil(rest.pos)
local cover = hearth.environment(p)
rest.valid = bed and bed.name == "hearth:bedroll"
and hearth.construction.allowed(rest.pos, player, "use")
and cover.shelter >= 0.6 and not cover.smoke
rest.validate_at = now + 0.25
end
interrupted = interrupted or not rest.valid
if interrupted then
session.resting = nil
hearth.feedback(player, "Rest interrupted / stay still in shelter with ventilation", "warning")
return false
end
rest.elapsed = rest.elapsed + math.min(math.max(dt, 0), 0.1)
if rest.elapsed + 0.000001 >= REST_SECONDS then
session.rest_warmth_pending = 8
session.resting, session.rest_ready_at = nil, C.get_us_time() / 1000000 + REST_COOLDOWN
hearth.feedback(player, "Rest complete / +8 warmth", "success")
return true
end
return true
end
local elapsed, clock_elapsed = 0, 0
C.register_globalstep(function(dt)
elapsed, clock_elapsed = elapsed + dt, clock_elapsed + dt
if clock_elapsed >= 10 then clock_update(); clock_elapsed = 0 end
if clock_elapsed >= 10 then hearth.clock_update(); clock_elapsed = 0 end
-- Small bounded steps for smooth sprint state; needs/HUD at 1 Hz.
for _, player in ipairs(C.get_connected_players()) do
local name = player:get_player_name()
local s, state = hearth.sessions[name], hearth.players[name]
if s then
local controls = player:get_player_control()
s.sprinting = controls.aux1 and controls.up and not controls.sneak and state.stamina > 8
s.blocking = player:get_wielded_item():get_name() == "hearth:spear" and controls.RMB
local speed = s.blocking and 0.6 or (s.sprinting and 1.45 or 1)
if speed ~= s.speed then player:set_physics_override({speed = speed, jump = 1, gravity = 1}); s.speed = speed end
local result = hearth.movement_step(player, dt)
if result then
if hearth.feedback_step then hearth.feedback_step(player, dt) end
hearth.rest_step(player, dt, player:get_player_control())
end
end
if elapsed < 1 then return end
@@ -179,20 +112,32 @@ C.register_globalstep(function(dt)
for _, player in ipairs(C.get_connected_players()) do
local name, pos = player:get_player_name(), player:get_pos()
local s, state = hearth.sessions[name], hearth.players[name]
if s and not waiting[name] and player:get_hp() > 0 then
local env = hearth.environment(pos)
env.sprinting = s.sprinting
local updated, danger = M.needs(state, env, step)
if env.heat > 0 then updated.wet = math.max(0, updated.wet - env.heat * step) end
hearth.players[name] = updated
s.damage = s.damage + danger + (env.smoke and step or 0)
if s.damage >= 4 then
player:set_hp(player:get_hp() - 1, {type = "set_hp", from = "mod"}); s.damage = 0
if s and state and not s.waiting and player:get_hp() > 0 then
if pos.y < -recovery_limit then
hearth.safe_arrival(player)
else
local env = hearth.environment(pos)
env.sprinting = s.sprinting
env.clothing = player:get_meta():get_int("hearth:cloak") == 1
local rack = env.shelter >= 0.6 and C.find_node_near(pos, 4, {"hearth:drying_rack"})
env.drying = env.heat > 0 or (env.shelter >= 0.6 and rack ~= nil)
local updated, danger = M.needs(state, env, step)
updated.stamina = state.stamina -- movement owns continuous stamina, not needs
local rest_completed = s.rest_warmth_pending ~= nil
if rest_completed then
updated.warmth = math.min(100, updated.warmth + s.rest_warmth_pending)
s.rest_warmth_pending = nil
end
hearth.players[name] = updated
if rest_completed then hearth.player_save(player) end
s.damage = s.damage + danger + (env.smoke and step or 0)
if s.damage >= 4 then
player:set_hp(player:get_hp() - 1, {type = "set_hp", from = "mod"}); s.damage = 0
end
hearth.update_hud(player, env)
s.save = s.save + step
if s.save >= 10 then hearth.player_save(player); s.save = 0 end
end
if pos.y < -100 then arrive(player) end
hearth.update_hud(player, env)
s.save = s.save + step
if s.save >= 10 then save(player); s.save = 0 end
end
end
end)
+105
View File
@@ -0,0 +1,105 @@
-- Pure save validation: no engine, JSON, filesystem, or global game state.
local S = {}
local needs = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100}
local kinds = {needs = true, claims = true, homes = true, access = true, location = true, spawn = true}
local function invalid()
error("Last Hearth: invalid save schema or data", 0)
end
local function record(value, fields)
if type(value) ~= "table" or getmetatable(value) ~= nil then invalid() end
for key in pairs(value) do
if not fields[key] then invalid() end
end
end
local function number(value, low, high, integer)
if type(value) ~= "number" or value ~= value or value < low or value > high
or (integer and value % 1 ~= 0) then invalid() end
return value
end
local function position(value, integer)
record(value, {x = true, y = true, z = true})
return {x = number(value.x, -31000, 31000, integer),
y = number(value.y, -31000, 31000, integer),
z = number(value.z, -31000, 31000, integer)}
end
local function player_name(value)
return type(value) == "string" and #value > 0 and #value <= 32
and value:match("^[%w_%-]+$") ~= nil
end
local function normalize(kind, value)
if kind == "needs" then
record(value, needs)
local result = {}
for key in pairs(needs) do result[key] = number(value[key], 0, 100) end
return result
elseif kind == "location" then
record(value, {latitude = true, longitude = true})
return {latitude = number(value.latitude, -90, 90),
longitude = number(value.longitude, -180, 180)}
elseif kind == "spawn" then
return position(value, false)
elseif kind == "access" then
if type(value) ~= "table" or getmetatable(value) ~= nil then invalid() end
local result = {}
for owner, members in pairs(value) do
if not player_name(owner) or type(members) ~= "table" or getmetatable(members) ~= nil then invalid() end
local normalized, count = {}, 0
for member, role in pairs(members) do
if not player_name(member) or member == owner
or (role ~= "resident" and role ~= "storekeeper") then invalid() end
count = count + 1
if count > 16 then invalid() end
normalized[member] = role
end
result[owner] = normalized
end
return result
end
-- JSON objects and empty arrays can both become {} in the engine. Nonempty
-- arrays are rejected by the string-key rule; an empty table is valid maps.
if type(value) ~= "table" or getmetatable(value) ~= nil then invalid() end
local result = {}
for owner, pos in pairs(value) do
if type(owner) ~= "string" or owner == "" then invalid() end
result[owner] = position(pos, true)
end
return result
end
-- value is already parsed JSON, NOT JSON text. Missing storage is the adapter's
-- responsibility. Legacy records must also be complete and valid: never clamp,
-- coerce strings, silently fill fields, or downgrade an unrecognized envelope.
function S.decode(kind, value)
if type(kind) ~= "string" or not kinds[kind] or type(value) ~= "table"
or getmetatable(value) ~= nil then invalid() end
local migrated = true
local envelope = value.schema ~= nil or value.kind ~= nil or value.data ~= nil
if kind == "claims" or kind == "homes" or kind == "access" then
-- Players may legitimately be named "schema", "kind", or "data".
-- Their legacy claim values are positions, never envelope scalars.
envelope = (value.schema ~= nil and type(value.schema) ~= "table")
or (value.kind ~= nil and type(value.kind) ~= "table")
or (value.data ~= nil and type(value.data) ~= "table")
end
if envelope then
record(value, {schema = true, kind = true, data = true})
if type(value.schema) ~= "number" or value.schema ~= 1 or value.kind ~= kind then invalid() end
value, migrated = value.data, false
end
return {schema = 1, kind = kind, data = normalize(kind, value)}, migrated
end
-- Each call owns its copy; callers cannot mutate future defaults.
function S.defaults()
local result = {}
for key, value in pairs(needs) do result[key] = value end
return result
end
return S
+113
View File
@@ -0,0 +1,113 @@
-- Original bounded seasonal appearance. No timers, world lists or clock replay.
return function(hearth, api)
local S = {COLUMN_HEIGHT = 12, MAX_COLUMN_SWAPS = 12}
local variants, canonical, availability = {}, {}, {}
local function variant(kind, season, texture, mesh)
variants[kind] = variants[kind] or {}
local short = kind .. "_" .. season:lower()
variants[kind][season] = {name="hearth:"..short, texture=texture, mesh=mesh}
canonical["hearth:"..short] = kind
end
for _, kind in ipairs({"tree_seedling", "young_tree", "mature_tree", "leaves"}) do
for _, season in ipairs({"Spring", "Autumn", "Winter"}) do
local suffix = season:lower()
local mesh = kind ~= "leaves" and ("hearth_"..kind
.. (season == "Winter" and kind ~= "tree_seedling" and "_winter" or "") .. ".obj") or nil
variant(kind, season, "hearth_" .. (kind == "leaves" and "leaves" or "tree_atlas") .. "_" .. suffix .. ".png", mesh)
end
end
for _, kind in ipairs({"meadow_grass", "fern", "wildflowers"}) do
variant(kind, "Winter", "hearth_"..kind.."_dormant.png")
if kind == "wildflowers" then variant(kind, "Autumn", "hearth_wildflowers_dormant.png") end
end
-- Patch nodes and picked food items have different names: never overwrite a
-- registered node with a craftitem, and never let picked food reset a patch.
variant("spring_greens_patch", "Spring", "hearth_spring_greens.png")
variant("autumn_mushrooms_patch", "Autumn", "hearth_autumn_mushrooms.png")
for kind in pairs(variants) do canonical["hearth:"..kind] = kind end
-- Extensions are installed by botany/diversity before node registration. Missing
-- bases in minimal mocks/optional content are skipped, never asserted.
function S.add_variants(kind, definitions, active_seasons)
assert(type(kind)=="string" and kind:match("^[a-z_]+$"), "invalid flora kind")
canonical["hearth:"..kind] = kind
for season, def in pairs(definitions or {}) do
assert(({Spring=true,Summer=true,Autumn=true,Winter=true})[season], "invalid season")
variant(kind, season, assert(def.texture), def.mesh)
end
availability[kind] = active_seasons
end
function S.kind(n)
local name = type(n) == "table" and n.name or n
return type(name) == "string" and (canonical[name] or name:match("^hearth:(.+)$")) or nil
end
function S.season(now)
local climate = hearth.climate_at and hearth.climate_at(now or os.time())
return climate and climate.season or "Summer"
end
function S.name_for(kind, now)
kind = S.kind(kind) or kind
local v = variants[kind] and variants[kind][S.season(now)]
return v and v.name or "hearth:"..kind
end
function S.available(kind, now)
kind = S.kind(kind) or kind
local season = S.season(now)
if availability[kind] then return availability[kind][season] == true end
if kind == "spring_greens_patch" then return season == "Spring" end
if kind == "autumn_mushrooms_patch" then return season == "Autumn" end
if kind == "wildflowers" then return season == "Spring" or season == "Summer" end
return not variants[kind] or season ~= "Winter"
end
function S.names()
local names = {}
for name in pairs(canonical) do names[#names+1] = name end
return names
end
function S.reconcile(p, n, now)
-- Appearance alone can change a claimed/hand-placed plant, but never its
-- provenance, age, harvest deadline, dig permission, drops or inventory.
n = n or api.get_node_or_nil(p)
if not n or not canonical[n.name] then return false end
local protection = hearth.external_protected or api.is_protected
if protection and protection(p, "") then return false end
local target = S.name_for(canonical[n.name], now)
if n.name == target then return false end
-- Recheck loaded identity before swapping; no set_node metadata erasure.
local current = api.get_node_or_nil(p)
if not current or current.name ~= n.name then return false end
api.swap_node(p, {name=target, param1=current.param1, param2=current.param2})
return true
end
function S.register()
local names = {}
for kind, seasons in pairs(variants) do
local base_name = "hearth:"..kind
local base = (api.registered_nodes or {})[base_name]
if base then
names[#names+1] = base_name
for _, v in pairs(seasons) do
local def = {}
for k, value in pairs(base) do def[k] = value end
def.groups = {}
for k, value in pairs(base.groups or {}) do def.groups[k] = value end
def.groups.not_in_creative_inventory = 1
-- A stage may now be a bark cube rather than the old sculpted
-- tree. Recolour foliage/sprites only; never flatten end-grain
-- and bark faces or reintroduce an obsolete mesh on a cube.
if base.drawtype ~= "normal" then
def.tiles = {v.texture}
if base.inventory_image then def.inventory_image = v.texture end
if base.wield_image then def.wield_image = v.texture end
end
if base.drawtype == "mesh" and v.mesh then def.mesh = v.mesh end
def.drop = base.drop == nil and base_name or base.drop
if not api.registered_nodes[v.name] then api.register_node(v.name, def) end
names[#names+1] = v.name
end
end
end
api.register_lbm({name="hearth:seasonal_flora", label="Reconcile seasonal flora appearance",
nodenames=names, run_at_every_load=true, action=function(p) S.reconcile(p) end})
end
return S
end
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+116
View File
@@ -0,0 +1,116 @@
-- Reviewed policy, not executable Wikipedia prose. No network, scheduler or map writes.
return function(hearth, api)
local E = {}
local B = assert(hearth.botany, "species effects require botany")
local articles=hearth.wiki_articles
if not articles and api.get_modpath then
articles=dofile(api.get_modpath("hearth").."/wiki_articles.lua")
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 finite(n) return type(n)=="number" and n==n and math.abs(n)<math.huge end
local function count(n,cap) return finite(n) and math.min(cap,math.max(0,math.floor(n))) or 0 end
local wiki="https://en.wikipedia.org/wiki/"
-- Original summaries of pages reviewed 2026-11-03. Numbers below are game
-- approximations, NOT measurements reported by these articles.
local reviewed={
alder={article="Alnus_glutinosa",note="Ecology: Frankia root nodules fix nitrogen and improve soil fertility."},
clover={article="Trifolium_repens",note="Cultivation and uses / Forage: symbiotic root-nodule nitrogen fixation."},
dog_lichen={article="Peltigera_canina",related="Peltigera",
note="Species Description identifies Nostoc; genus Description explains nitrogen fixation. Genus-level mechanism, not a measured species rate."},
sphagnum={article="Sphagnum_palustre",related="Sphagnum",
note="Species introduction describes water absorption; genus introduction explains cation exchange and acidification. Acidification is genus-level evidence."},
duckweed={article="Lemna_minor",related="Lemnoideae",
note="Species Cultivation describes floating carpets; subfamily Duckweed in natural environments describes shade. Shade is a group-level mechanism."},
birch_polypore={article="Fomitopsis_betulina",
note="Distribution, habitat and ecology: brown rot of birch wood; living-tree parasitism is not simulated."},
fly_agaric={article="Amanita_muscaria",
note="Distribution and ecology: ectomycorrhizal tree associations. Game host list is deliberately narrower; carbon and water exchange are not simulated."},
}
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
-- that every tagged trait has been reviewed or implemented.
function E.source(value)
local r=record(value); if not r then return nil end
local evidence=reviewed[r.id]
local snapshot=hearth.wiki_articles or articles
local article=type(snapshot)=="table" and snapshot[r.id]
return {source_species=r.latin,source_url=evidence and wiki..evidence.article
or (article and article.url) or wiki..r.latin:gsub(" ","_"),
reviewed=evidence~=nil,reviewed_on=evidence and "2026-11-03" or nil,
note=evidence and evidence.note or "Identity reference only; no species-effect evidence reviewed here.",
evidence_url=evidence and wiki..(evidence.related or evidence.article) or nil,
-- Snapshot revision is identity provenance; the intro may omit the
-- reviewed body section. Never present it as a pinned effect claim.
identity_revision=article and article.revision or nil,
identity_revision_url=article and article.revision_url or nil}
end
function E.sources()
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 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.",
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.",
resin="Not simulated by species effects: no automatic resin production or extraction.",
}
function E.describe(value)
local r=record(value); if not r then return {} end
local out={}
local function add(title,status,game)
local row=E.source(r); row.title,row.status,row.game=title,status,game
out[#out+1]=row
end
for _,tag in ipairs(tags) do if r.tags[tag] then
local status,game="descriptive",descriptions[tag]
if tag=="nitrogenfixer" then status,game="simulated",nitrogen
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."
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."
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."
elseif r.id=="duckweed" then
status,game="simulated","Active floating mats reduce nearby submerged freshwater habitat light by 1.5 each, at most 4 mats / 6 light; never brighten an already-dark site and never lower it below 2. Source-water nodes remain intact."
else game="Not simulated: no species-specific erosion, bank stabilization, rock weathering or hydrology effect for this engineer tag." end
end
add(tag,status,game or "Not simulated: natural-history descriptor only.")
end end
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
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
-- Pure derivation from ONE base sample. Callers must supply fresh baseline h
-- on each observation, never feed a derived result back as a new baseline.
function E.habitat(h,observations)
local out=copy(h)
if type(h)~="table" or h.loaded~=true then return out end
local seen=type(observations)=="table" and observations or h.species_counts or {}
out.source_moisture,out.source_ph,out.source_light=h.moisture,h.ph,h.light
if (h.water or 0)==0 and (h.aquatic==nil or h.aquatic=="land" or h.aquatic=="bank") then
local mats=count(seen.sphagnum,2)
if mats>0 then
if finite(h.moisture) then out.moisture=math.max(0,math.min(1,h.moisture+mats*0.1)) end
if finite(h.ph) then out.ph=math.max(3.5,math.min(8.5,h.ph-mats*0.2)) end
end
elseif h.freshwater==true and h.aquatic=="submerged" and finite(h.light) then
out.light=h.light-math.min(count(seen.duckweed,4)*1.5,math.max(0,h.light-2))
end
return out
end
return E
end
+138
View File
@@ -0,0 +1,138 @@
-- Read-only offline UI. Injected APIs; no registrations, network or map writes.
return function(hearth, api, journal)
local R = {}
local esc = api.formspec_escape
local function session(player)
return player and hearth.sessions and hearth.sessions[player:get_player_name()]
end
local function finite(n) return type(n)=="number" and n==n and math.abs(n)<math.huge end
local function position(p)
return type(p)=="table" and finite(p.x) and finite(p.y) and finite(p.z)
and math.abs(p.x)<31000 and math.abs(p.y)<31000 and math.abs(p.z)<31000
end
-- UTF-8 boundaries without relying on Lua 5.3's optional utf8 library.
-- At most 8000 code points / 32000 bytes enter a body; each page is 4000.
local function clip(value, limit, skip)
local s = type(value)=="string" and value or ""
local i, count, first = 1, 0, nil
skip = skip or 0
while i<=#s and count<skip+limit do
if count==skip then first=i end
local b=s:byte(i)
i=i+(b<128 and 1 or b<224 and 2 or b<240 and 3 or 4)
count=count+1
end
local out=first and s:sub(first,i-1) or ""
return out:gsub("[%z\1-\8\11-\31\127]", ""), i<=#s
end
local function safe(value, limit) return esc((clip(value,limit or 512))) end
local function record(value)
local B=hearth.botany
return B and type(B.get)=="function" and B.get(value) or nil
end
function R.resolve(value)
if type(value)~="string" or #value>120 then return nil end
value=value:match("^%s*(.-)%s*$")
-- Explicit IDs or exact canonical Latin names only. No node names, URLs,
-- substrings, common-name guessing or network fallback in chat input.
local B=hearth.botany
if not B or type(B.list)~="function" then return nil end
for _,r in ipairs(B.list()) do
if value==r.id or value==r.latin then return record(r.id) end
end
end
function R.look(player)
if not player or not player.get_pos or not player.get_look_dir or not player.get_properties
or not api.raycast or not api.get_node_or_nil then return nil end
local p,d=player:get_pos(),player:get_look_dir()
if not position(p) or not position(d) then return nil end
local height=(player:get_properties() or {}).eye_height or 1.55
if not finite(height) or height<0 or height>3 then return nil end
local length=math.sqrt(d.x*d.x+d.y*d.y+d.z*d.z)
if length<0.001 then return nil end
local eye={x=p.x,y=p.y+height,z=p.z}
local finish={x=eye.x+d.x/length*6,y=eye.y+d.y/length*6,z=eye.z+d.z/length*6}
for hit in api.raycast(eye,finish,false,false) do
if hit.type=="node" then
local at=hit.intersection_point
if not position(hit.under) or not position(at)
or (at.x-eye.x)^2+(at.y-eye.y)^2+(at.z-eye.z)^2>36.000001 then return nil end
local node=api.get_node_or_nil(hit.under)
-- Stop at the first surface, even if unknown/unloaded. Never see through it.
if not node or node.name=="ignore" then return nil end
local B=hearth.botany
local id=B and B.kind and B.kind(node.name)
return id and record(id) or nil
end
end
end
local function effects(r)
local E=hearth.species_effects
local lines={"Current effects are game policy, not measured Wikipedia constants. Natural-history tags are not all modeled."}
local rows=E and E.describe and E.describe(r) or {}
if #rows==0 then lines[#lines+1]="Effect descriptions unavailable in this runtime." end
for i,row in ipairs(rows) do
if i>16 then break end
lines[#lines+1]=(clip(row.title,80)).." / "..(row.status=="simulated" and "SIMULATED" or "DESCRIPTIVE (not simulated)")
.."\n"..(clip(row.game,900)).."\nEvidence: "..(clip(row.note,600))
.."\n"..(clip(row.source_url,512)).."\n"..(clip(row.evidence_url,512))
end
return table.concat(lines,"\n\n")
end
function R.show(player, value, tab, page)
local s=session(player)
local r=record(value)
if not s or not r then return false,"Unknown species. Use an exact catalogue ID or Latin name." end
tab=tab==2 and 2 or 1
local a=hearth.wiki_articles and hearth.wiki_articles[r.id]
local intro=type(a)=="table" and a.kind=="wikipedia_intro"
local missing=type(a)=="table" and a.kind=="original_notes"
local title=intro and "Wikipedia excerpt (offline)" or "No Wikipedia article cached (offline)"
local body=tab==2 and effects(r) or ((intro or missing) and a.text
or "No article is available in this runtime. No replacement Wikipedia text has been invented.")
local bounded,cut=clip(body,8000)
local second=clip(bounded,4000,4000)
local pages=second~="" and 2 or 1
page=type(page)=="number" and page==page and math.max(1,math.min(pages,math.floor(page))) or 1
s.species_reader={id=r.id,tab=tab,page=page}
local provenance={}
if intro or missing then
for _,pair in ipairs({{"Source title",a.title},{"Attribution",a.attribution},{"License",a.license},
{"License URL",a.license_url},{"Canonical URL",a.url},{"Permanent revision",a.revision_url},
{"History / contributors",a.history_url},{"Revision UTC",a.revision_timestamp},
{"Retrieved UTC",a.retrieved},{"Changes",a.changes}}) do
provenance[#provenance+1]=pair[1]..": "..(clip(pair[2],512))
end
end
local f=(journal and journal(14,12) or "formspec_version[6]size[14,12]bgcolor[#1e1e2e;true]")
.."label[0.6,0.5;"..safe(r.common.." ("..r.latin..")").."]"
.."label[0.6,1;"..safe(title).."]"
.."tabheader[0.6,1.8;reader_tab;Article,Current effects;"..tab..";false;false]"
-- Empty field names make these textareas read-only but copyable.
.."textarea[0.6,2.1;12.8,4.7;;;"..safe(clip(bounded,4000,(page-1)*4000),4000).."]"
.."label[0.6,6.9;Page "..page.." / "..pages..(cut and " / display limited to 8000 characters" or "").."]"
.."textarea[0.6,7.3;12.8,2.65;;;"..safe(table.concat(provenance,"\n"),6000).."]"
.."textarea[0.6,10;12.8,0.7;;;"..safe("Introduction only, not the full wiki. Plain text; URLs are copyable, never opened automatically. Not identification, foraging, food preparation or medical advice.").."]"
..(page>1 and "button[0.6,11;2,0.6;reader_previous;Previous]" or "")
..(page<pages and "button[2.8,11;2,0.6;reader_next;Next]" or "")
.."button[6,11;3,0.6;reader_botany;Botanical guide]button_exit[10,11;3.3,0.6;reader_close;Close]"
api.show_formspec(player:get_player_name(),"hearth:species",f)
return true
end
function R.handle(player, fields)
local s=session(player)
local state=s and s.species_reader
if not state then return false end
if fields.quit or fields.reader_close then s.species_reader=nil; return true end
if fields.reader_botany then
s.species_reader=nil
if hearth.botanical_guide then hearth.botanical_guide(player,s.botany_query,s.botany_page) end
elseif fields.reader_tab=="1" or fields.reader_tab=="2" then
R.show(player,state.id,tonumber(fields.reader_tab),1)
elseif fields.reader_next then R.show(player,state.id,state.tab,state.page+1)
elseif fields.reader_previous then R.show(player,state.id,state.tab,state.page-1) end
-- Client-provided text, IDs, URLs and page numbers are never used.
return true
end
return R
end
+758
View File
@@ -0,0 +1,758 @@
-- Original loaded-only succession. No ABMs, map emergence, timers or world registry.
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}
local PREFIX, PERIOD = "hearth:", 60
local kinds = {meadow_grass=true, wildflowers=true, fern=true, litter=true, fungi=true,
deadwood=true, young_tree=true, mature_tree=true, tree_seedling=true, dung=true,
spring_greens_patch=true, autumn_mushrooms_patch=true}
local trees = {tree_seedling=true, young_tree=true, mature_tree=true}
local diversity = hearth.diversity
local botany = hearth.botany
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.
if botany and not effects and api.get_modpath then
effects=dofile(api.get_modpath("hearth").."/species_effects.lua")(hearth,api)
hearth.species_effects=effects
end
if botany then
for _,r in ipairs(botany.list()) do
if r.group~="trees" then kinds[r.id]=true end
end
end
if diversity then
for _,k in ipairs(diversity.plants) do kinds[k]=true end
for _,species in ipairs(diversity.species_order) do
for _,stage in ipairs({"seedling","young","mature"}) do
local k=species.."_"..stage; kinds[k]=true; trees[k]=true
end
end
end
local offsets = {{1,0}, {0,1}, {-1,0}, {0,-1}, {2,1}, {-2,-1}, {1,-2}, {-1,2}}
local seen, ring, ring_index, cursor, player_index, elapsed = {}, {}, 1, 0, 0, 0
local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end
local function at(p, x, y, z) return {x=p.x+x, y=p.y+y, z=p.z+z} end
local function position(p)
if type(p) ~= "table" then return nil end
for _, axis in ipairs({"x", "y", "z"}) do
local n = p[axis]
if type(n) ~= "number" or n ~= n or math.abs(n) > 30990 then return nil end
end
return {x=math.floor(p.x+0.5), y=math.floor(p.y+0.5), z=math.floor(p.z+0.5)}
end
local function node(p)
local n = api.get_node_or_nil(p)
if n and n.name ~= "ignore" then return n end
end
local function kind(n)
local name=type(n)=="table" and n.name or n
if type(name)~="string" then return nil end
return hearth.seasons and hearth.seasons.kind(name) or name:match("^hearth:(.+)$")
end
local function family(k) return diversity and diversity.family(k) or k end
local function number(meta, field, maximum)
local n = tonumber(meta:get_string(field))
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 markers = {"owner", "planter", "placed_by", "player_placed", "hearth_placed", "hearth_player_placed", "planted_at"}
local function marked(p)
local meta = api.get_meta(p)
-- Unknown third-party placement/provenance metadata is not ours to erase.
-- Keep the named fallback for minimal API mocks and older integrations.
if meta.to_table then
local value = meta:to_table()
for field, text in pairs(value and value.fields or {}) do
local harvest=botany and field=="botany_ready_at" and tonumber(text)
and tonumber(text)>=0 and tonumber(text)<math.huge
if field:sub(1,5) ~= "succ_" and not harvest and text ~= "" and tostring(text) ~= "0" then return true end
end
end
for _, field in ipairs(markers) do
local value = meta:get_string(field)
if value ~= "" and value ~= "0" then return true end
end
return false
end
local function permitted(p)
-- Autonomous nature has no claim-owner identity, including on shared claims.
if hearth.claim_owner_at then
local owner, ambiguous = hearth.claim_owner_at(p)
if owner or ambiguous then return false end
end
-- Player access deliberately rejects empty names, even on unclaimed ground.
-- Nature must instead explicitly exclude claims and external protection.
local protection = hearth.external_protected or api.is_protected
return not (protection and protection(p, "")) and not marked(p)
end
local function ground(p)
local n = node(p)
return n and (n.name == PREFIX.."grass" or n.name == PREFIX.."soil"
or (diversity and (diversity.is_ground(n) or diversity.is_rock(n))))
end
local function natural(p, n)
return n and (kinds[kind(n)] or (botany and api.get_meta(p):get_string("succ_component")~=""))
and api.get_meta(p):get_string("succ_natural") == "1"
end
-- Semantic substrate observations never replace water, logs or terrain.
-- A water stand occupies AIR above a separately conserved source column.
local function substrate(p)
local below=at(p,0,-1,0)
local n=node(below)
if not n then return nil end
local k=kind(n)
if ground(below) then return {ground=k,substrate=k,water=0,base=below} end
if not botany then return nil end
if k=="water_source" then
for depth=1,S.MAX_WATER_DEPTH do
local q=at(p,0,-depth,0)
local w=node(q)
if not w or kind(w)~="water_source" or not permitted(q) then return nil end
local bottom=at(p,0,-depth-1,0)
local b=node(bottom)
if not b or not permitted(bottom) then return nil end
if kind(b)~="water_source" then
if not ground(bottom) and kind(b)~="sand" then return nil end
return {ground=kind(b),substrate="water",water=depth,base=bottom,freshwater=true}
end
end
elseif natural(below,n) and permitted(below) then
if k=="deadwood" or k=="litter" then
return {ground=k,substrate=k,water=0,base=below,host=api.get_meta(below):get_string("succ_species")}
elseif family(k)=="log" or family(k)=="young_tree" or family(k)=="mature_tree" then
local id=botany.kind(n) or api.get_meta(below):get_string("succ_species")
return {ground="bark",substrate="bark",water=0,base=below,host=id}
end
end
end
local function site(p)
local n, below = node(p), at(p,0,-1,0)
if not n or not substrate(p) or not permitted(p) or not permitted(below) then return false end
if n.name ~= "air" and not natural(p, n) then return false end
if not botany and diversity and n.name~="air" and not diversity.supports_ground(kind(n),node(below)) then return false end
-- A conservative buffer around farms/builds/markers; unknown/unloaded fails closed.
for x=-1,1 do for z=-1,1 do for y=0,2 do
local q = at(p,x,y,z)
local other = node(q)
if not other or marked(q) then return false end
local name = family(kind(other))
if other.name ~= "air" and not natural(q, other)
and name ~= "leaves" and name ~= "log"
and not (botany and kind(other)=="water_source" and permitted(q))
and not (diversity and diversity.is_worldgen_flora(other) and permitted(q)) then return false end
end end end
return true
end
function S.habitat(raw)
local p = position(raw)
local h = {grass=0, flowers=0, cover=0, water=0, canopy=0, loaded=true, moisture=0, light=0,
edible=0,pollination=0,species_counts={}}
if not p then h.loaded=false; return h end
local support=node(at(p,0,-1,0))
h.ground=kind(support)
local sub=substrate(p)
if botany and not sub then h.loaded=false; return h end
if botany then
h.ground,h.substrate,h.water,h.host=sub.ground,sub.substrate,sub.water,sub.host
h.freshwater=sub.freshwater==true
h.aquatic="land"; h.near_water=0; h.nursery=0; h.root_fungi={}
-- Support-class pH is a deliberately coarse game observation.
h.ph=({peat=4.5,podzol=5,alpine_turf=5.5,granite=5,basalt=6.5,
limestone=7.5,slate=5.5,sandstone=5.5,scree=5.5,stone=5.5,
forest_floor=5.5,alluvium=6.5,grass=6.5,soil=6.5,sand=6.5,bark=5.5,deadwood=5.5,litter=5.5})[h.ground]
end
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 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
local q = at(p,x,y,z)
local n = node(q)
if not n then h.loaded=false
else
local k = kind(n)
local f=family(k)
local evergreen=k=="pine_leaves" or k=="spruce_leaves"
or (diversity and (diversity.tree_info(k)=="pine" or diversity.tree_info(k)=="spruce"))
local shade=evergreen and 1 or foliage
if k == "water_source" or k == "water_flowing" then
if botany then h.near_water=h.near_water+1 else h.water=h.water+1 end
end
-- A tree's own crown shades its neighbours, not its root's
-- establishment test. Otherwise every grown tree self-starves.
if f == "leaves" and not (botany and api.get_meta(q):get_string("succ_component")==key(p)) then h.canopy=h.canopy+0.15*shade end
if natural(q,n) then
local owner_root=api.get_meta(q):get_string("succ_component")
local component=owner_root~="" and owner_root~=key(q)
if botany and permitted(q) then
local r=botany.get(botany.kind(n))
if r then
-- One rooted mat, not every tree component or stacked sprite.
-- Use the existing stencil; never a new radius scan.
if y==0 and not component and botany.active(r.id,season) then
local below=at(q,0,-1,0)
local b=node(below)
local supported=(r.id=="sphagnum" and b and (kind(b)=="peat" or kind(b)=="alluvium"))
or (r.id=="duckweed" and b and kind(b)=="water_source")
or (r.id=="alder" and trees[k] and ground(below))
if supported and permitted(below) then
h.species_counts[r.id]=(h.species_counts[r.id] or 0)+1
end
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
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 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
end
if k == "meadow_grass" then h.grass=h.grass+1 end
if k == "wildflowers" then h.flowers=h.flowers+1 end
if k == "fern" or trees[k] or (diversity and diversity.is_worldgen_flora(k)) then h.cover=h.cover+1 end
if f == "mature_tree" and api.get_meta(q):get_string("succ_tree_stage")=="" then h.canopy=h.canopy+shade end
if f == "young_tree" and api.get_meta(q):get_string("succ_tree_stage")=="" then h.canopy=h.canopy+0.5*shade end
if diversity and not botany and diversity.edible(k) then h.edible=h.edible+1 end
if diversity and (not botany or not botany.kind(k)) and diversity.pollinator(k,season) then h.pollination=h.pollination+1 end
end
end
end end end
local light = api.get_node_light and api.get_node_light(p, 0.5)
h.light = math.max(0, math.min(15, light or 15) - h.canopy*3)
local biome = api.get_biome_data and api.get_biome_data(p)
h.heat=biome and biome.heat or 50
h.moisture = math.min(1, (biome and biome.humidity or 45)/100 + math.min(0.5,(h.near_water or h.water)*0.1) + h.canopy*0.08)
local surfaces=hearth.biomes and hearth.biomes.surface
local surface=surfaces and surfaces(support)
local soil_moisture=surface and surface.moisture
or ({forest_floor=0.6,podzol=0.4,alluvium=0.85,peat=1,alpine_turf=0.35})[h.ground]
if soil_moisture then h.moisture=math.min(1,(h.moisture+soil_moisture)/2) end
if botany then
if h.water>0 then
local r=botany.get(node(p))
h.aquatic=r and r.niche.aquatic or ((math.abs(p.x*17+p.z*31)%2==0) and "surface" or "submerged")
h.moisture=1
elseif h.near_water>0 and h.moisture>=0.65 then h.aquatic="bank" end
end
return effects and effects.habitat(h,h.species_counts) or h
end
local function active(p, now)
local k = key(p)
local previous = seen[k]
if not previous then
local evicted = ring[ring_index]
if evicted then seen[evicted]=nil end
ring[ring_index]=k; ring_index=ring_index%S.CACHE_LIMIT+1
end
seen[k]=now
-- Restart, cache eviction, clock rewind and long absences only rebaseline.
if not previous or now <= previous or now-previous > S.MAX_GAP then return 0 end
return now-previous
end
local function tree_clear(p)
-- Species mature geometry reaches five nodes/±1.5. Conservative
-- 5x5x6 target volume, checked loaded/protected/marked, never emerged.
for x=-2,2 do for z=-2,2 do for y=0,5 do
local q = at(p,x,y,z)
local n = node(q)
if not n or not permitted(q) then return false end
if not (x==0 and y==0 and z==0) and n.name ~= "air" then
if not natural(q,n) or trees[kind(n)] then return false end
end
end end end
return true
end
local tree_epoch
local function plan_for(species,stage)
local plan=diversity and diversity.tree_plan and diversity.tree_plan(species,stage)
if type(plan)~="table" or #plan==0 or #plan>S.MAX_TREE_CELLS then return nil end
local root,seen_cells=false,{}
for _,c in ipairs(plan) do
if type(c)~="table" then return nil end
for _,axis in ipairs({"x","y","z"}) do
local v=c[axis]
if type(v)~="number" or v~=v or v%1~=0 or math.abs(v)>(axis=="y" and 9 or 4) or (axis=="y" and v<0) then return nil end
end
local k=kind(c.name)
local f=family(k)
if f~="log" and f~="leaves" and f~="young_tree" and f~="mature_tree" then return nil end
local rootkind=species=="generic" and (stage=="young" and "young_tree" or "mature_tree") or species.."_"..stage
local wood=species=="generic" and "log" or species.."_log"
local leaves=species=="generic" and "leaves" or species.."_leaves"
if k~=rootkind and k~=wood and k~=leaves then return nil end
local rotation=c.param2 or 0
if type(rotation)~="number" or rotation~=rotation or rotation%1~=0 or rotation<0 or rotation>23 then return nil end
if c.name:sub(1,7)~=PREFIX or seen_cells[key(c)] then return nil end
seen_cells[key(c)]=true
if c.x==0 and c.y==0 and c.z==0 then root=true end
end
return root and plan or nil
end
local function owned(p,root,expected)
local n=node(p)
local m=api.get_meta(p)
return n and natural(p,n) and m:get_string("succ_component")==root
and kind(n)==kind(expected.name) and (n.param2 or 0)==(expected.param2 or 0)
end
local function components(p,species,stage)
local plan=plan_for(species,stage)
if not plan then return nil end
local cells={}
for _,c in ipairs(plan) do cells[key(at(p,c.x,c.y,c.z))]=c end
return cells,plan
end
local function clear_component(q)
api.swap_node(q,{name="air"})
local m=api.get_meta(q)
for _,f in ipairs({"succ_natural","succ_component","succ_species","succ_age","succ_stress"}) do m:set_string(f,"") end
end
local function tree_transition(p,target,removed)
-- Reserve before generating even the read-only geometry, so eight
-- competing roots cannot each construct a full plan in one second.
local epoch=os.time()
if tree_epoch==epoch then return false end
tree_epoch=epoch
local m=api.get_meta(p)
local current=removed or kind(node(p))
local species,stage=diversity.tree_info(current)
species=species or "generic"
stage=stage or ({young_tree="young",mature_tree="mature"})[current]
local _,next_stage=diversity.tree_info(target)
next_stage=next_stage or ({young_tree="young",mature_tree="mature"})[target]
local previous=m:get_string("succ_tree_stage")
if removed then previous=stage end
if previous=="" then previous=nil end
local old,oldplan={},{}
if previous then
old,oldplan=components(p,species,previous)
if not old then return false end
end
local plan={}
if next_stage then plan=plan_for(species,next_stage); if not plan then return false end end
local root=key(p)
local destinations,extras={},{}
for _,c in ipairs(oldplan) do destinations[key(at(p,c.x,c.y,c.z))]={c=c,old=true} end
for _,c in ipairs(plan) do
local id=key(at(p,c.x,c.y,c.z))
destinations[id]=destinations[id] or {}; destinations[id].next=c
end
-- One full plan preflight/application per wall-clock second globally,
-- including failed attempts. Never multiply tree work by player count.
for id,cell in pairs(destinations) do
local c=cell.c or cell.next
local q=at(p,c.x,c.y,c.z)
local n=node(q)
local support=at(q,0,-1,0)
if not position(q) or not n or not node(support) or not permitted(q) or not permitted(support) then return false end
if id~=root then
if cell.old then
if n.name~="air" and not owned(q,root,old[id]) then return false end
elseif n.name~="air" then return false end
elseif removed then
if n.name~="air" then return false end
elseif not natural(q,n) then return false end
-- Epiphytes are not tree components. Refuse growth into them; on
-- death remove only explicitly natural occupants losing their wood.
if not next_stage and cell.old and family(kind(c.name))=="log" then
local above=at(q,0,1,0)
local a=node(above)
if not a or not permitted(above) then return false end
if a.name~="air" and not destinations[key(above)] and not (botany and botany.kind(a) and natural(above,a)) then return false end
if botany and botany.kind(a) and natural(above,a) and not destinations[key(above)] then extras[key(above)]=above end
end
end
-- All destinations passed before the first write. Lua callbacks execute
-- synchronously; no yielding, inventory callbacks or map emergence here.
for _,q in pairs(extras) do clear_component(q) end
for id,cell in pairs(destinations) do
local c=cell.c or cell.next
local q=at(p,c.x,c.y,c.z)
if id~=root then
if cell.next then
local nextcell=cell.next
local name=hearth.seasons and hearth.seasons.name_for(kind(nextcell.name)) or nextcell.name
api.swap_node(q,{name=name,param2=nextcell.param2 or 0})
local cm=api.get_meta(q)
cm:set_string("succ_natural","1"); cm:set_string("succ_component",root)
cm:set_string("succ_species",species)
elseif node(q).name~="air" then clear_component(q) end
end
end
m:set_string("succ_tree_stage",next_stage or "")
if removed then
m:set_string("succ_component",""); m:set_string("succ_natural","")
if hearth.invalidate_environment then hearth.invalidate_environment(p) end
return true
end
m:set_string("succ_component",next_stage and root or "")
return true
end
local function change(p, target)
-- All callers preflight; recheck source and natural support immediately before writing.
if not site(p) then return false end
local h=botany and S.habitat(p)
if botany then
if not h.loaded then return false end
if target and botany.kind(target) then
if not botany.suitable(target,h,hearth.seasons and hearth.seasons.season() or "Summer") then return false end
elseif target and (h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter") then return false end
elseif diversity and target and not diversity.supports_ground(target,node(at(p,0,-1,0))) then return false end
local expanding=family(target)=="young_tree" or family(target)=="mature_tree"
local managed=api.get_meta(p):get_string("succ_tree_stage")~=""
if diversity and (expanding or managed) and (botany or managed or hearth.biomes) then
if not tree_transition(p,target) then return false end
elseif expanding and not tree_clear(p) then return false end
-- A wood/litter substrate can carry one natural fungus/epiphyte above
-- it. Do not leave that occupant hanging when its substrate is spent.
if botany and (kind(node(p))=="deadwood" or kind(node(p))=="litter") then
local above=at(p,0,1,0)
local a=node(above)
if not a or not permitted(above) then return false end
if a.name~="air" then
if not natural(above,a) or not botany.kind(a) then return false end
clear_component(above)
end
end
local origin=diversity and diversity.tree_info(kind(node(p)))
local name = target and (hearth.seasons and hearth.seasons.name_for(target) or PREFIX..target) or "air"
api.swap_node(p, {name=name,param2=target=="deadwood" and 12 or 0})
local m = api.get_meta(p)
local species=diversity and diversity.tree_info(target)
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")
if hearth.invalidate_environment then hearth.invalidate_environment(p) end
return true
end
-- Optional after-dig handoff. Root identity comes from the old node, never
-- executable serialized metadata. Only this root's matching components go.
function S.tree_removed(raw,oldnode)
local p=position(raw)
if not p or not diversity or not oldnode then return false end
local k=kind(oldnode)
local f=family(k)
if f~="young_tree" and f~="mature_tree" then return false end
if not node(p) or node(p).name~="air" or not permitted(p) or not permitted(at(p,0,-1,0)) then return false end
return tree_transition(p,nil,k)
end
if api.register_on_dignode then api.register_on_dignode(function(p,oldnode) S.tree_removed(p,oldnode) end) end
local function nutrients(p, amount)
local below = at(p,0,-1,0)
if not ground(below) or not permitted(below) then return end
if diversity and not diversity.is_ground(node(below)) then return end
local m = api.get_meta(below)
m:set_string("succ_nutrients", tostring(math.max(0,math.min(8,number(m,"succ_nutrients",8)+amount))))
return true
end
local function disperse(p, target, serial)
local d = offsets[serial%#offsets+1]
local q = at(p,d[1],0,d[2])
if not site(p) or not site(q) or node(q).name ~= "air" then return false end
local dest = S.habitat(q)
if not dest.loaded then return false end
if botany and botany.kind(target) then
if not botany.suitable(target,dest,hearth.seasons and hearth.seasons.season() or "Summer") then return false end
elseif diversity and not diversity.suitable(target,dest) then return false end
if family(target) == "tree_seedling" and (dest.canopy >= 1.5 or dest.grass+dest.flowers >= 15) then return false end
if target == "meadow_grass" and (dest.light < 9 or dest.moisture>=0.75) then return false end
if target == "wildflowers" and (dest.light < 11 or dest.grass >= 10) then return false end
if target == "fern" and (dest.light > 12 or dest.moisture < 0.55) then return false end
if target == "spring_greens_patch" and (dest.light < 9 or dest.moisture < 0.55) then return false end
if target == "autumn_mushrooms_patch" and (dest.light > 10 or dest.moisture < 0.6) then return false end
if hearth.seasons and not hearth.seasons.available(target) then return false end
local m = api.get_meta(q)
-- Destination cooldown survives grazing, decay and restart; no repeated seed rerolls.
local now = os.time()
if number(m,"succ_next") > now then return false end
if not change(q,target) then return false end
m:set_string("succ_next", tostring(now+PERIOD))
nutrients(q,-1)
return true
end
local function observe(p, now)
if not site(p) then seen[key(p)]=nil; return false end
local component=api.get_meta(p):get_string("succ_component")
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)
local record=botany and botany.get(k)
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))
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))
local serial = number(m,"succ_serial",1000000000)+1
m:set_string("succ_serial", tostring(serial))
local fertility = number(api.get_meta(at(p,0,-1,0)),"succ_nutrients",8)
if n.name == "air" then
if warm and (botany or not hearth.seasons or hearth.seasons.season(now)~="Winter")
and age >= (fertility>0 and 90 or 180) and h.light >= (botany and 2 or 5) then
local target = h.light<10 and h.moisture>=0.55 and "fern" or "meadow_grass"
if h.light>=11 and h.grass>=2 and h.grass<10 and (p.x+p.z)%4==0 then target="wildflowers" end
if hearth.seasons then
if hearth.seasons.season(now)=="Autumn" and target=="wildflowers" then target="meadow_grass" end
if hearth.seasons.available("spring_greens_patch",now) and h.light>=9 and h.moisture>=0.55
and (p.x*3+p.z*5)%7==0 then target="spring_greens_patch" end
if hearth.seasons.available("autumn_mushrooms_patch",now) and h.light<=10 and h.moisture>=0.6
and (p.x+p.z)%5==0 then target="autumn_mushrooms_patch" end
end
if botany then
-- No optimistic meadow fallback for bark, saturated banks,
-- water or a missing botanical niche. Use the actual returned
-- node kind, never the descriptor ID (trees have stage IDs).
h.include_trees=age>=7200 and h.canopy<1.5 and (p.x*7+p.z*11)%23==0
target=botany.choose(p,h,season)
if not target then return false end
elseif diversity then
target=diversity.choose_recruit(p,h,hearth.seasons and hearth.seasons.season(now) or "Summer") or target
-- Saturated riparian soils cannot silently fall back to meadow;
-- exposed bedrock admits lichen only, never soil replacement.
if not diversity.supports_ground(target,node(at(p,0,-1,0)))
or (target=="meadow_grass" and h.moisture>=0.75) then return false end
end
if h.light<9 and target=="meadow_grass" then return false end
if change(p,target) then nutrients(p,-1); return true end
end
return false
end
-- Perennials rest rather than die at first frost. Autumn litter keeps
-- entering the nutrient cycle in winter; decomposition remains active.
if not decomposer and (not warm or dormant) then
if f=="mature_tree" and serial%5==0 then disperse(p,"litter",serial) end
return false
end
local harsh = (k=="meadow_grass" and h.light<8) or (k=="wildflowers" and (h.light<10 or h.grass>=12))
or (k=="fern" and (h.moisture<0.5 or h.light>13)) or (trees[k] and (h.moisture<0.25 or h.light<4))
if record then
if not botany.suitable(k,h,season) then harsh=true end
elseif diversity and diversity.is_worldgen_flora(k) and not diversity.suitable(k,h) then harsh=true end
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
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]
if age>=lifetime then
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
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
local below=at(p,0,-1,0)
local soil=api.get_meta(below)
local fixed=number(soil,"succ_fixed_nitrogen",2)
local delta=math.min(botany.nitrogen(k,fixed),math.max(0,4-fertility))
if delta>0 and fertility<4 and permitted(p) and permitted(below) and nutrients(p,delta) then
soil:set_string("succ_fixed_nitrogen",tostring(fixed+delta))
end
end
local seed_age=21600
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
if exchange>0 then nutrients(p,-exchange) end
return true
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
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
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;
-- never grant the fungus a second nutrient payout of its own.
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))))
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)
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)
if recruit then return change(p,recruit) end
end
if k=="clover" and age>=900 then
local below=at(p,0,-1,0)
local soil=api.get_meta(below)
local fixed=number(soil,"succ_fixed_nitrogen",2)
-- At most two nitrogen additions per support, across deaths and
-- restart. No endlessly farmable item or per-tick fertilizer loop.
if fixed<2 and fertility<4 and permitted(p) and permitted(below) then
nutrients(p,1); soil:set_string("succ_fixed_nitrogen",tostring(fixed+1))
end
end
if age>=21600 then
if diversity.is_rock(node(at(p,0,-1,0))) then return change(p,nil) end
return change(p,"litter")
end
if age>=900 and serial%3==0 then disperse(p,k,serial) end
elseif k=="spring_greens_patch" or k=="autumn_mushrooms_patch" then
if age>=900 and serial%3==0 then disperse(p,k,serial) end
elseif k=="meadow_grass" or k=="wildflowers" or k=="fern" then
-- Occasional native woody recruitment in old meadows, not an instant forest.
local recruitment_age = h.grass>=12 and fertility==0 and 14400 or 7200
if k=="meadow_grass" and age>=recruitment_age and h.canopy<(diversity and 1.5 or 0.5) and h.moisture>=0.4
and (p.x*7+p.z*11)%23==0 then
local recruit=diversity and diversity.choose_tree(p,h) or (not diversity and "tree_seedling")
if recruit then return change(p,recruit) end
end
if age>=(k=="wildflowers" and 14400 or 21600) then return change(p,"litter") end
if age>=900 and serial%3==0 then disperse(p,k,serial) end
end
return false
end
local function nearby(p, fn)
-- Nine cells, with one mutation at most. API input is never an unbounded radius.
for x=-1,1 do for z=-1,1 do
local q = at(p,x,0,z)
if fn(q) then return true end
end end
return false
end
function S.forage(raw)
local p = position(raw)
if not p or not node(p) or not permitted(p) then return false end
return nearby(p,function(q)
local n = node(q)
local k=kind(n)
if not natural(q,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
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))
return true
end)
end
function S.deposit(raw)
local p = position(raw)
if not p or not node(p) or not permitted(p) then return false end
return nearby(p,function(q)
local n = node(q)
if not n or n.name~="air" then return false end
if number(api.get_meta(q),"succ_next")>os.time() then return false end
if not change(q,"dung") then return false end
api.get_meta(q):set_string("succ_next",tostring(os.time()+PERIOD))
return true
end)
end
function S.step(dtime)
if type(dtime)~="number" or dtime~=dtime or dtime<0 then return 0 end
elapsed=math.min(S.INTERVAL,elapsed+dtime)
if elapsed<S.INTERVAL then return 0 end
elapsed=0 -- Never replay a stalled frame's backlog.
local players = api.get_connected_players()
if #players==0 then seen,ring,ring_index={},{},1; return 0 end
local now, work = os.time(), 0
local side = S.RADIUS*2+1
for _=1,S.BUDGET do
player_index=player_index%#players+1
local p = position(players[player_index]:get_pos())
if p then
local index = math.floor(cursor/#players)%(side*side)
local x,z = index%side-S.RADIUS, math.floor(index/side)-S.RADIUS
for y=6,-5,-1 do
local q = at(p,x,y,z)
-- At most twelve visual swaps per probe (96/second globally).
-- Includes legacy mapgen leaves above the first natural ground.
if hearth.seasons then
local current = node(q)
if current then hearth.seasons.reconcile(q,current,now) end
end
if diversity then diversity.bootstrap(q,node(q)) end
local sub=botany and substrate(q)
if ground(at(q,0,-1,0)) or sub then
observe(q,now)
-- Visit an epiphyte and its support in the SAME bounded
-- column. Stopping above deadwood would freeze its clock.
if not sub or (sub.substrate~="bark" and sub.substrate~="deadwood" and sub.substrate~="litter") then break end
end
end
end
cursor=(cursor+1)%(side*side*#players)
work=work+1
end
return work
end
api.register_globalstep(S.step)
local reload_nodes = {"hearth:grass", "hearth:soil"}
if botany then reload_nodes[#reload_nodes+1]="hearth:water_source" end
if diversity then
for k in pairs(diversity.grounds) do reload_nodes[#reload_nodes+1]=PREFIX..k end
for k in pairs(diversity.rocks) do reload_nodes[#reload_nodes+1]=PREFIX..k end
if botany then
reload_nodes[#reload_nodes+1]=PREFIX.."log"
for _,species in ipairs(diversity.species_order) do reload_nodes[#reload_nodes+1]=PREFIX..species.."_log" end
end
end
for k in pairs(kinds) do reload_nodes[#reload_nodes+1]=PREFIX..k end
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",
nodenames=reload_nodes, run_at_every_load=true,
action=function(p)
seen[key(p)]=nil
if ground(p) then seen[key(at(p,0,1,0))]=nil end
if botany then
seen[key(at(p,0,1,0))]=nil
local component=api.get_meta(p):get_string("succ_component")
if component~="" then seen[component]=nil end
end
end})
return S
end
+1524
View File
File diff suppressed because one or more lines are too long
+301
View File
@@ -0,0 +1,301 @@
-- Loaded-only fauna: bounded work, no drops, combat, restores or catch-up.
return function(hearth, api)
local GLOBAL, LOCAL, PLAYER, RADIUS, BUDGET = 32, 6, 6, 48, 12
local kinds = {"hare", "deer", "fox"}
local species = {
hare={cap=16,radius=0.23,height=0.85,body=0.48,length=0.32},
deer={cap=10,radius=0.32,height=1.52,body=1.12,length=0.49},
fox={cap=6,radius=0.23,height=0.80,body=0.57,length=0.43},
}
local active, counts, total, serial = {}, {hare=0,deer=0,fox=0}, 0, 0
local clock, credit, recruitment, player_cursor, recruit_kind, cursor = 0, 0, 0, 0, 0, 0
local actions, births, meals, grazed, dung = 0, 0, 0, 0, 0
local directions={{1,0},{1,1},{0,1},{-1,1},{-1,0},{-1,-1},{0,-1},{1,-1}}
local offsets={{8,0},{-8,0},{0,8},{0,-8},{16,8},{-16,-8},{24,0},{0,24}}
local function delta(dt)
if type(dt)~="number" or dt~=dt or dt<0 or dt==math.huge then return 0 end
return math.min(dt,1)
end
local function valid(p)
if type(p)~="table" then return false end
for _, axis in ipairs({"x","y","z"}) do
local n=p[axis]
if type(n)~="number" or n~=n or math.abs(n)>30900 then return false end
end
return true
end
local function at(p,x,y,z) return {x=p.x+x,y=p.y+y,z=p.z+z} end
local function cell(p) return {x=math.floor(p.x+0.5),y=math.floor(p.y+0.5),z=math.floor(p.z+0.5)} end
local function distance(a,b) return (a.x-b.x)^2+(a.y-b.y)^2+(a.z-b.z)^2 end
local function permitted(p)
if hearth.claim_owner_at then
local owner, ambiguous=hearth.claim_owner_at(p)
if owner or ambiguous then return false end
end
local protect=hearth.external_protected or api.is_protected
return type(protect)=="function" and not protect(p,"")
end
local function node(p)
if not valid(p) or not permitted(p) then return nil end
local n=api.get_node_or_nil(p)
if n and n.name~="ignore" then return n end
end
local function natural(p,n)
if not n or api.get_meta(p):get_string("succ_natural")~="1" then return false end
local k=hearth.seasons and hearth.seasons.kind(n) or n.name:match("^hearth:(.+)$")
if hearth.seasons and not hearth.seasons.available(k) then return false end
if hearth.diversity then return hearth.diversity.edible(k) end
return k=="meadow_grass" or k=="fern"
end
local function clear(p)
local n=node(p)
return n and (n.name=="air" or natural(p,n) or n.name=="hearth:dung")
end
local function safe(p,kind)
if not valid(p) or not permitted(p) then return false end
local s=species[kind]
-- A yaw-independent square body envelope; only flat natural terrain.
local r=math.max(s.radius,s.length)
for _, x in ipairs({-r,0,r}) do for _, z in ipairs({-r,0,r}) do
local n=node(cell(at(p,x,-0.05,z)))
if not n or not (n.name=="hearth:grass" or n.name=="hearth:soil"
or (hearth.diversity and hearth.diversity.is_ground(n))) then return false end
for _, y in ipairs({0.05,1.1,s.height,2.1}) do
if not clear(cell(at(p,x,y,z))) then return false end
end
end end
return true
end
local function path(a,b,kind)
if not valid(a) or not valid(b) or distance(a,b)>2.26 then return false end
for i=0,6 do
local t=i/6
if not safe({x=a.x+(b.x-a.x)*t,y=a.y+(b.y-a.y)*t,z=a.z+(b.z-a.z)*t},kind) then return false end
end
return true
end
local function habitat(p)
local s=hearth.succession
if not s or not s.habitat then return false end
local c=cell(at(p,0,0.1,0))
local h=s.habitat(c)
if not h or h.loaded~=true or ((h.grass or 0)<=0 and (h.cover or 0)<=0 and (h.edible or 0)<=0) then return false end
-- cover also counts trees: a forest without edible meadow/fern is not
-- a recruitment or breeding site. Read at most nine loaded cells.
for x=-1,1 do for z=-1,1 do
local q=at(c,x,0,z)
if natural(q,node(q)) then return true end
end end
return false
end
local function nearby(p,r,kind,exclude)
local n=0
for e in pairs(active) do
local q=e.object:get_pos()
if e~=exclude and valid(q) and (not kind or e.kind==kind) and distance(p,q)<=r*r then n=n+1 end
end
return n
end
local function room(p,kind)
if total>=GLOBAL or counts[kind]>=species[kind].cap or nearby(p,16)>=LOCAL then return false end
local close=false
for _, player in ipairs(api.get_connected_players()) do
local q=player:get_pos()
if valid(q) and distance(p,q)<=RADIUS*RADIUS then
close=true
if nearby(q,RADIUS)>=PLAYER then return false end
end
end
return close
end
local function forget(e)
if active[e] then active[e]=nil; total=total-1; counts[e.kind]=counts[e.kind]-1 end
end
local function remove(e)
forget(e) -- release before remove/on_deactivate: each prey counts once
e.object:remove()
end
local function spawn(p,kind)
if not safe(p,kind) or not habitat(p) or not room(p,kind) then return false end
local obj=api.add_entity(p,"hearth:"..kind)
local e=obj and obj:get_luaentity()
return e and active[e] or false
end
local function callback_site(e)
local p=e.object:get_pos()
if not safe(p,e.kind) then return nil end
local c=cell(at(p,0,0.1,0))
-- forage/deposit may mutate ANY cell of their 3x3 stencil.
for x=-1,1 do for z=-1,1 do
if not safe({x=c.x+x,y=c.y-0.5,z=c.z+z},e.kind) then return nil end
end end
return c
end
local function move(e,dx,dz)
local a=e.object:get_pos()
local len=math.sqrt(dx*dx+dz*dz)
if len<0.001 then return false end
local stride=e.kind=="fox" and 1.2 or 0.9
local b=at(a,dx/len*stride,0,dz/len*stride)
if not path(a,b,e.kind) then return false end
if nearby(b,16,nil,e)>=LOCAL then return false end
for _, player in ipairs(api.get_connected_players()) do
local p=player:get_pos()
if valid(p) and distance(b,p)<=RADIUS*RADIUS and nearby(p,RADIUS,nil,e)>=PLAYER then return false end
end
e.object:set_velocity({x=0,y=0,z=0})
e.object:set_pos(b) -- short swept ground stride; gravity remains enabled
local atan2=math.atan2 or function(y,x) return math.atan(y,x) end
e.object:set_yaw(atan2(-dx,dz)) -- authored forward +Z
return true
end
local function threat(e,p)
local closest,best=nil,64
for _, player in ipairs(api.get_connected_players()) do
local q=player:get_pos()
if valid(q) and distance(p,q)<best then closest,best=q,distance(p,q) end
end
if e.kind~="fox" then
for other in pairs(active) do
local q=other.object:get_pos()
if other.kind=="fox" and valid(q) and distance(p,q)<best then closest,best=q,distance(p,q) end
end
end
return closest
end
local function breed(e,p)
local needed=e.kind=="fox" and 2 or 3
if e.age<60 or clock<e.breed_at or e.food<needed or e.hunger>35 or not habitat(p) then return false end
for mate in pairs(active) do
local q=mate.object:get_pos()
if mate~=e and mate.kind==e.kind and mate.age>=60 and mate.food>=needed
and mate.hunger<=35 and clock>=mate.breed_at and valid(q) and path(p,q,e.kind) then
for _, d in ipairs(directions) do
local b=at(p,d[1]*0.8,0,d[2]*0.8)
if path(p,b,e.kind) and spawn(b,e.kind) then
e.food=e.food-needed; mate.food=mate.food-needed
e.breed_at=clock+90; mate.breed_at=clock+90
births=births+1; return true
end
end
return false
end
end
return false
end
local function think(e)
local p=e.object:get_pos()
if not safe(p,e.kind) then remove(e); return end
local danger=threat(e,p)
if danger then
if not move(e,p.x-danger.x,p.z-danger.z) then move(e,-(p.z-danger.z),p.x-danger.x) end
return
end
local site=callback_site(e)
if e.dung_at and clock>=e.dung_at and site then
local s=hearth.succession
if s and s.deposit and s.deposit(site) then dung=dung+1 end
e.dung_at=nil -- one attempt per meal, never a growing queue
return
end
if breed(e,p) then return end
if e.kind=="fox" then
local prey,best=nil,144
for other in pairs(active) do
local q=other.object:get_pos()
if other.kind=="hare" and valid(q) and distance(p,q)<best and safe(q,"hare") then prey,best=other,distance(p,q) end
end
if prey and e.hunger>=15 then
local q=prey.object:get_pos()
if best<=1.44 and clock>=e.feed_at and path(p,q,"fox") and safe(q,"hare") then
remove(prey); meals=meals+1; e.food=math.min(6,e.food+1)
e.hunger=math.max(0,e.hunger-35); e.feed_at=clock+20; e.dung_at=clock+8
else move(e,q.x-p.x,q.z-p.z) end
return
end
elseif e.hunger>=15 and clock>=e.feed_at and site and habitat(p) then
local edible=false
for x=-1,1 do for z=-1,1 do
local q=at(site,x,0,z)
if natural(q,node(q)) then edible=true end
end end
local s=hearth.succession
if edible and s and s.forage and s.forage(site) then
e.food=math.min(6,e.food+1); e.hunger=math.max(0,e.hunger-30)
e.feed_at=clock+12; e.dung_at=clock+8; grazed=grazed+1; return
end
end
for i=1,3 do
local d=directions[(e.id+e.turn+i)%#directions+1]
local q=at(p,d[1]*0.9,0,d[2]*0.9)
if habitat(q) and move(e,d[1],d[2]) then e.turn=e.turn+1; return end
end
e.turn=e.turn+1
end
for _, kind in ipairs(kinds) do
local s=species[kind]
api.register_entity("hearth:"..kind, {
initial_properties={physical=true,collide_with_objects=true,
collisionbox={-s.radius,0,-s.length,s.radius,s.body,s.length},
visual="mesh",mesh="hearth_"..kind..".obj",textures={"hearth_"..kind..".png"},
visual_size={x=1,y=1},static_save=false,pointable=false,makes_footstep_sound=false},
on_activate=function(self,staticdata,dtime_s)
if active[self] then return end
local p=self.object:get_pos()
-- add_entity may activate synchronously; enforce caps centrally here.
if (staticdata and staticdata~="") or (dtime_s and dtime_s~=0)
or not safe(p,kind) or not habitat(p) or not room(p,kind) then self.object:remove(); return end
serial=serial+1; self.id=serial; self.kind=kind; self.age=0; self.turn=0
self.hunger=20; self.food=0; self.feed_at=clock+8; self.breed_at=clock+60
self.next_ai=clock+2; active[self]=true; total=total+1; counts[kind]=counts[kind]+1
self.object:set_armor_groups({immortal=1})
self.object:set_acceleration({x=0,y=-9.81,z=0})
self.object:set_velocity({x=0,y=0,z=0})
end,
on_step=function(self,dt)
if not active[self] then return end
local t=delta(dt)
self.age=self.age+t; self.hunger=math.min(100,self.hunger+t*0.6)
if self.age>=900 or self.hunger>=100 then remove(self); return end
local p=self.object:get_pos()
if not valid(p) or not permitted(p) or not node(cell(p)) then remove(self) end
end,
on_punch=function() return true end,
on_deactivate=forget,
})
end
api.register_globalstep(function(dt)
local t=delta(dt)
clock=clock+t; credit=math.min(BUDGET,credit+t*BUDGET); recruitment=recruitment+t
local list={}
for e in pairs(active) do list[#list+1]=e end
table.sort(list,function(a,b) return a.id<b.id end)
for _=1,#list do
if credit<1 then break end
cursor=cursor%#list+1
local e=list[cursor]
if active[e] and clock>=e.next_ai then
credit=credit-1; actions=actions+1; e.next_ai=clock+2; think(e)
end
end
if recruitment<30 or credit<1 then return end
recruitment=0; credit=credit-1; actions=actions+1
local players=api.get_connected_players()
if #players==0 then return end
player_cursor=player_cursor%#players+1; recruit_kind=recruit_kind%#kinds+1
local center=players[player_cursor]:get_pos()
if not valid(center) then return end
local kind=kinds[recruit_kind]
if kind=="fox" and nearby(center,RADIUS,"hare")<2 then return end
for _, d in ipairs(offsets) do for y=2,-3,-1 do
local p={x=math.floor(center.x+d[1]+0.5),y=math.floor(center.y+0.5)+y-0.5,z=math.floor(center.z+d[2]+0.5)}
if spawn(p,kind) then return end
end end
end)
return {species="hearth:hare",global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL,ai_budget=BUDGET,
status=function()
return {total=total,hare=counts.hare,deer=counts.deer,fox=counts.fox,
global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL,
actions=actions,births=births,meals=meals,grazed=grazed,dung=dung}
end}
end