Lots of content
This commit is contained in:
Executable
+39
@@ -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
|
||||
Executable
+322
@@ -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
|
||||
Executable
+359
@@ -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
|
||||
Executable
+227
@@ -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
|
||||
Executable
+63
@@ -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)
|
||||
Executable
+59
@@ -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
|
||||
Executable
+228
@@ -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
File diff suppressed because it is too large
Load Diff
Executable
+261
@@ -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
|
||||
Executable
+230
@@ -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
|
||||
Executable
+230
@@ -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
@@ -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})
|
||||
|
||||
Executable
+139
@@ -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
|
||||
Executable
+143
@@ -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,
|
||||
})
|
||||
Executable
+162
@@ -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
@@ -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")
|
||||
|
||||
Executable
+26
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Executable
+43
@@ -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
|
||||
Executable
+126
@@ -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
|
||||
Executable
+377
@@ -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)
|
||||
Executable
+114
@@ -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
|
||||
Executable
+258
@@ -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
@@ -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)
|
||||
|
||||
Executable
+105
@@ -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
|
||||
Executable
+113
@@ -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
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
+116
@@ -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
|
||||
Executable
+138
@@ -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
|
||||
Executable
+758
@@ -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
|
||||
Executable
+1524
File diff suppressed because one or more lines are too long
Executable
+301
@@ -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
|
||||
Reference in New Issue
Block a user