Files
2026-11-03 12:41:09 -05:00

410 lines
22 KiB
Lua
Executable File

-- Standalone Lua 5.1+ lifecycle regressions; arg[1] is players.lua.
local target = assert(arg[1], "players.lua path required")
local root = assert(target:match("^(.*[/\\])"), "module directory required")
local schema = dofile(root .. "schema.lua")
local persistence = dofile(root .. "persistence.lua")
local total = 0
local function test(name, fn)
local old_core, old_hearth, old_vector = core, hearth, vector
local ok, message = pcall(fn)
core, hearth, vector = old_core, old_hearth, old_vector
assert(ok, name .. ": " .. tostring(message)); total = total + 1
end
local function copy(value)
if type(value) ~= "table" then return value end
local result = {}; for k, v in pairs(value) do result[k] = copy(v) end; return result
end
local spawn = {x = 4, y = 4, z = -8}
local function harness(saved_spawn)
local h = {callbacks = {}, after = {}, logs = {}, kicks = {}, connected = {}, moves = {}, gui = {}, messages = {},
loaded = true, ground = true, clock = 1000000, emerges = {}, homes = {}}
local function store()
local m = {values = {}, ints = {}, writes = 0}
function m:get_string(key)
if self.read_fail then error("PRIVATE read payload") end
return self.values[key] or ""
end
function m:set_string(key, value)
self.writes = self.writes + 1
if self.write_fail then error("PRIVATE save payload") end
self.values[key] = value
end
function m:get_int(key) return self.ints[key] or 0 end
function m:set_int(key, value)
if key == "hearth:arrived" then h.moves[#h.moves + 1] = "arrived" end
self.ints[key] = value
end
return m
end
local json, serial = {}, 0
local C = {registered_nodes = {
air = {walkable = false}, ground = {walkable = true, groups = {soil = 1}},
leaves = {walkable = true, groups = {leaves = 1, hearth_canopy = 1}},
log = {walkable = true, groups = {tree = 1}},
floor = {walkable = true, groups = {stone = 1, hearth_building = 1}},
water = {walkable = false, liquidtype = "source", groups = {water = 1}},
fire = {walkable = false, damage_per_second = 4},
}}
function C.write_json(value) serial = serial + 1; local raw = "fixture:" .. serial; json[raw] = copy(value); return raw end
function C.parse_json(raw, _, return_error)
assert(return_error == true, "Persistence must suppress parser payload logging"); return copy(json[raw])
end
function C.get_node_or_nil(pos)
if not h.loaded then return nil end
if h.node_at then return h.node_at(pos) end
if h.water_at and h.water_at(pos) then return {name = "water"} end
if h.fire_at and h.fire_at(pos) then return {name = "fire"} end
if h.ground and pos.y == 3 then return {name = "ground"} end
return {name = "air"}
end
function C.get_mod_storage() return h.world end
function C.register_on_joinplayer(fn) h.callbacks.join = fn end
function C.register_on_leaveplayer(fn) h.callbacks.leave = fn end
function C.register_on_shutdown(fn) h.callbacks.shutdown = fn end
function C.register_on_respawnplayer(fn) h.callbacks.respawn = fn end
function C.register_on_punchplayer(fn) h.callbacks.punch = fn end
C.get_connected_players = function() return h.connected end
C.get_player_by_name = function(name) for _, p in ipairs(h.connected) do if p.name == name then return p end end end
C.log = function(level, text) assert(level == "error"); h.logs[#h.logs + 1] = text end
C.kick_player = function(name, text) h.kicks[#h.kicks + 1] = text end
C.after = function(delay, fn) h.after[#h.after + 1] = {delay = delay, fn = fn} end
C.get_us_time = function() h.clock = h.clock + 1000; return h.clock end
C.settings = {get = function(_, key) if key == "mapgen_limit" then return "512" end end}
C.emerge_area = function(minimum, maximum, fn)
h.emerges[#h.emerges + 1] = {minimum = copy(minimum), maximum = copy(maximum), callback = fn}
end
h.world = store()
if saved_spawn then h.world.values.spawn = C.write_json({schema = 1, kind = "spawn", data = saved_spawn}) end
local function claim_allowed() return true end
core, vector = C, {round = function(p) return {x = math.floor(p.x + 0.5), y = math.floor(p.y + 0.5), z = math.floor(p.z + 0.5)} end}
hearth = {players = {}, sessions = {}, schema = schema, has_location = true, claim_allowed = claim_allowed}
hearth.persistence = persistence(schema, C, h.world)
hearth.home_for = function(name) local p = h.homes[name]; return p and copy(p) end
hearth.in_own_claim = function(pos, name) return h.claim_owner == name and h.in_claim ~= false end
hearth.store_home = function(name, pos) assert(hearth.in_own_claim(pos, name)); h.homes[name] = copy(pos); h.home_saves = (h.home_saves or 0) + 1 end
hearth.notify = function(_, message) h.messages[#h.messages + 1] = message end
hearth.texture = function() return "texture.png" end
hearth.inventory = function(p) p.inventory_calls = p.inventory_calls + 1 end
hearth.make_hud = function(p) p.hud_calls = p.hud_calls + 1 end
hearth.guide = function(p) h.gui[#h.gui + 1] = {kind = "guide", player = p} end
hearth.location_form = function(p) h.gui[#h.gui + 1] = {kind = "location", player = p} end
function h.player(name)
local meta = store()
local p = {name = name or "fixture-player", meta = meta, pos = {x = 0, y = 4, z = 0},
inventory_calls = 0, hud_calls = 0, sizes = {}, widths = {}, hp = 20}
function p:get_player_name() return self.name end
function p:get_meta() return self.meta end
function p:get_pos() return self.pos end
function p:get_inventory()
return {set_size = function(_, key, value) self.sizes[key] = value end,
set_width = function(_, key, value) self.widths[key] = value end}
end
function p:set_properties(value)
self.property_calls = (self.property_calls or 0) + 1
self.properties = self.properties or {}
for k, v in pairs(value) do self.properties[k] = copy(v) end
end
function p:set_armor_groups(value) self.armor = value end
function p:set_clouds(value) self.clouds = value end
function p:set_pos(value) self.pos = copy(value); h.moves[#h.moves + 1] = "pos" end
function p:set_velocity(value) self.velocity = copy(value); h.moves[#h.moves + 1] = "velocity" end
function p:get_hp() return self.hp end
function p:set_hp(value) self.hp = value end
h.connected[#h.connected + 1] = p
return p, meta
end
function h.drain_zero()
local count = 0
while count < 100 do
local item
for i, queued in ipairs(h.after) do if queued.delay == 0 then item = table.remove(h.after, i); break end end
if not item then return end
count = count + 1; item.fn()
end
error("Unbounded scheduled callback loop")
end
dofile(target)
return h
end
local function no_private(h)
for _, messages in ipairs({h.logs, h.kicks}) do
for _, message in ipairs(messages) do assert(not message:find("PRIVATE", 1, true)) end
end
end
test("lifecycle callbacks expose safe handlers and disable PvP", function()
local h = harness(spawn)
assert(h.callbacks.join == hearth.player_join and h.callbacks.leave == hearth.player_leave)
assert(h.callbacks.respawn == hearth.player_respawn and h.callbacks.punch() == true)
assert(type(h.callbacks.shutdown) == "function" and type(hearth.safe_arrival) == "function")
end)
test("initial arrival validates spawn and only then marks arrived", function()
local h = harness(spawn); local p, meta = h.player(); h.callbacks.join(p)
assert(meta:get_int("hearth:arrived") == 1 and p.pos.x == spawn.x and p.pos.y == spawn.y)
assert(table.concat(h.moves, ",") == "pos,velocity,arrived")
assert(not hearth.sessions[p.name].waiting and #h.kicks == 0)
end)
test("normal reconnect preserves saved location without home/spawn teleport", function()
local h = harness(spawn); local p, meta = h.player(); meta.ints["hearth:arrived"] = 1
p.pos = {x = 123, y = -40, z = 456}; h.callbacks.join(p)
assert(#h.moves == 0 and p.pos.x == 123 and p.pos.y == -40 and p.pos.z == 456)
assert(not hearth.sessions[p.name].waiting)
end)
test("initial arrival waits for emergence and does not set arrived early", function()
local h = harness(); h.loaded = false
local p, meta = h.player(); h.callbacks.join(p)
assert(meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting and #h.moves == 0)
assert(#h.emerges == 1 and h.emerges[1].minimum.x == -64 and h.emerges[1].minimum.z == -64
and h.emerges[1].maximum.x == 64 and h.emerges[1].maximum.z == 64
and h.emerges[1].minimum.y == 0 and h.emerges[1].maximum.y == 320,
"Bounded arrival must emerge surfaces through 318 plus feet/head clearance")
h.loaded, h.ground = true, true
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(meta:get_int("hearth:arrived") == 1 and not hearth.sessions[p.name].waiting)
assert(p.pos.y == 4 and #h.kicks == 0)
end)
test("shared emergence resolves every waiting player without kicking", function()
local h = harness(); h.loaded = false
local p, m = h.player("one"); local q, n = h.player("two")
h.callbacks.join(p); h.callbacks.join(q)
assert(#h.emerges == 1 and hearth.sessions.one.waiting and hearth.sessions.two.waiting)
h.loaded, h.ground = true, true
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(m:get_int("hearth:arrived") == 1 and n:get_int("hearth:arrived") == 1)
assert(not hearth.sessions.one.waiting and not hearth.sessions.two.waiting and #h.kicks == 0)
assert(p.pos.x ~= q.pos.x or p.pos.z ~= q.pos.z, "Arrival must not overlap another player")
end)
test("leaving during emergence removes that waiter and stale callback cannot move them", function()
local h = harness(); h.loaded = false
local p, meta = h.player(); h.callbacks.join(p); h.callbacks.leave(p)
h.loaded = true; h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and not hearth.sessions[p.name])
end)
test("no safe bounded location leaves player waiting and preserves metadata", function()
local h = harness(); h.loaded = false; h.ground = false
local p, meta = h.player(); h.callbacks.join(p)
h.loaded = true; h.ground = false; h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting)
assert(#h.kicks == 0 and #h.moves == 0 and hearth.players[p.name].food == 85)
end)
test("high altitude patch is found without settling on leaves or logs", function()
local h = harness(); local p, meta = h.player()
h.node_at = function(pos)
assert(math.abs(pos.x) <= 64 and math.abs(pos.z) <= 64 and pos.y <= 320,
"Surface search must remain bounded")
if pos.y == 318 and math.abs(pos.x) <= 2 and math.abs(pos.z) <= 2 then return {name = "ground"} end
if pos.y == 290 then return {name = pos.x < 0 and "leaves" or "log"} end
return {name = "air"}
end
h.callbacks.join(p)
assert(#h.moves == 0 and #h.emerges == 1)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(meta:get_int("hearth:arrived") == 1 and p.pos.y == 319 and p.pos.x == 0 and p.pos.z == 0)
end)
test("natural biome surface groups support high altitude arrival", function()
for _, groups in ipairs({{soil = 1, hearth_habitat = 1}, {stone = 1, hearth_geology = 1},
{sand = 1, falling_node = 1}, {crumbly = 3}, {crumbly = 3, falling_node = 1}, {clay = 1, crumbly = 2}}) do
local h = harness(); core.registered_nodes.variant = {walkable = true, groups = groups}
h.node_at = function(pos) return {name = pos.y == 250 and "variant" or "air"} end
local p, meta = h.player(); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(meta:get_int("hearth:arrived") == 1 and p.pos.y == 251)
end
end)
test("canopy or log only worlds never fabricate a safe fallback", function()
for _, node in ipairs({"leaves", "log", "floor"}) do
local h = harness()
h.node_at = function(pos) return {name = pos.y == 250 and node or "air"} end
local p, meta = h.player(); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting)
end
end)
test("isolated high cliff pillar is not a landing patch", function()
local h = harness()
h.node_at = function(pos)
return {name = pos.x == 0 and pos.z == 0 and pos.y <= 250 and "ground" or "air"}
end
local p, meta = h.player(); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting)
end)
test("unsafe upper surfaces cannot fall through to underground cave floors", function()
local h = harness()
h.node_at = function(pos)
if pos.y >= 260 or pos.y == 200 then return {name = "ground"} end
return {name = "air"}
end
local p, meta = h.player(); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting)
end)
test("high altitude wet and damaging surfaces remain unsafe", function()
for _, hazard in ipairs({"water", "fire"}) do
local h = harness()
h.node_at = function(pos)
if pos.y == 250 then return {name = "ground"} end
return {name = pos.y == 251 and hazard or "air"}
end
local p, meta = h.player(); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting)
end
end)
test("bounded arrivals authorize unclaimed land but reject foreign claims", function()
for _, claimed in ipairs({false, true}) do
local h = harness()
local claims = claimed and {owner = {x = 0, y = 251, z = 0}} or {}
local policy = dofile(root .. "access.lua")(claims, {owner = {newcomer = "resident"}})
local checks = 0
hearth.claim_allowed = function(pos, name, action)
assert(action == "arrival" and name == "newcomer"); checks = checks + 1
return policy.allowed(pos, name, action)
end
h.node_at = function(pos)
return {name = pos.y == 250 and math.abs(pos.x) <= 2 and math.abs(pos.z) <= 2 and "ground" or "air"}
end
local p, meta = h.player("newcomer"); h.callbacks.join(p)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(checks > 0)
assert(meta:get_int("hearth:arrived") == (claimed and 0 or 1))
assert(hearth.sessions[p.name].waiting == claimed)
end
end)
test("saved home retains safe constructed floor support", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.claim_owner = p.name
h.node_at = function(pos) return {name = pos.y == 249 and "floor" or "air"} end
p.pos = {x = 20, y = 250, z = 20}
assert(hearth.set_home(p))
p.pos = {x = 100, y = 4, z = 100}
assert(hearth.go_home(p)); h.drain_zero()
assert(p.pos.x == 20 and p.pos.y == 250 and p.pos.z == 20)
end)
test("sethome requires own claim and dry grounded support, then stores safe integer position", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
assert(not hearth.set_home(p) and (h.home_saves or 0) == 0)
p.pos = {x = 0, y = 4, z = 0}
h.claim_owner = p.name
local ok, message = hearth.set_home(p)
assert(ok and message:find("Safe home", 1, true) and h.home_saves == 1)
assert(h.homes[p.name].x == 0 and h.homes[p.name].y == 4 and h.homes[p.name].z == 0)
h.water_at = function(pos) return pos.y == 4 end
assert(not hearth.set_home(p) and h.home_saves == 1)
end)
test("sethome rejects foreign claim and unsupported or obstructed positions", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.claim_owner = "another-owner"; assert(not hearth.set_home(p))
h.claim_owner = p.name; h.ground = false; assert(not hearth.set_home(p))
h.ground = true; h.node_at = function(pos)
if pos.y == 3 then return {name = "ground"} end
if pos.y == 5 then return {name = "ground"} end
return {name = "air"}
end
assert(not hearth.set_home(p))
end)
test("home command revalidates home and moves only on explicit request", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.homes[p.name] = {x = 20, y = 4, z = 20}
p.pos = {x = 100, y = 4, z = 100}
local before = #h.moves
local ok = hearth.go_home(p)
assert(ok and #h.moves == before + 2 and p.pos.x == 20 and p.pos.z == 20)
assert(h.messages[#h.messages]:find("verified safe", 1, true))
end)
test("home command without a saved home still performs safe world-spawn recovery", function()
local h = harness(spawn); local p = h.player("new-player"); h.callbacks.join(p)
p.pos = {x = 100, y = 4, z = 100}
local ok, message = hearth.go_home(p)
h.drain_zero()
assert(ok and message:find("No personal home", 1, true))
assert(p.pos.x == spawn.x and p.pos.y == spawn.y and p.pos.z == spawn.z)
end)
test("unsafe saved home falls back to validated spawn without teleporting into fluid", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.homes[p.name] = {x = 20, y = 4, z = 20}
h.water_at = function(pos) return pos.x >= 15 and pos.x <= 25 and pos.z >= 15 and pos.z <= 25
and (pos.y == 4 or pos.y == 5) end
assert(hearth.go_home(p)); h.drain_zero()
assert(p.pos.x == spawn.x and p.pos.y == spawn.y and p.pos.z == spawn.z)
end)
test("respawn preserves food, water, wetness and inventory while limiting recovery needs", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
local state = hearth.players[p.name]
state.food, state.water, state.wet, state.warmth, state.stamina = 19, 27, 44, 35, 2
p.pos = {x = 200, y = 4, z = 200}
assert(h.callbacks.respawn(p) == true)
h.drain_zero()
assert(#h.emerges == 1 and hearth.sessions[p.name].waiting)
h.emerges[1].callback(nil, nil, 0); h.drain_zero()
assert(state.food == 19 and state.water == 27 and state.wet == 44)
assert(state.warmth == 35 and state.stamina == 35 and p.inventory_calls == 1)
assert(p.pos.y == 4 and not hearth.sessions[p.name].waiting and #h.kicks == 0)
end)
test("death recovery avoids a recently lethal home and uses emergence-safe fallback", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.homes[p.name] = {x = 20, y = 4, z = 20}; p.pos = {x = 20, y = 4, z = 20}
hearth.sessions[p.name].recovery_avoid = {x = 20, y = 4, z = 20, until_us = h.clock + 10000000}
h.callbacks.respawn(p)
h.drain_zero()
assert((p.pos.x ~= 20 or p.pos.z ~= 20) and not hearth.sessions[p.name].waiting)
end)
test("failed player load is sanitized and cannot establish a session", function()
local h = harness(spawn); local p, meta = h.player(); meta.read_fail = true; h.callbacks.join(p)
assert(not hearth.players[p.name] and not hearth.sessions[p.name] and #h.kicks == 1)
assert(#h.moves == 0 and meta.writes == 0); no_private(h)
end)
test("saved spawn and home never accept unloaded, liquid, unsupported, or damaging nodes", function()
local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p)
h.node_at = function(pos)
if pos.y == 3 then return {name = "ground"} end
if pos.x == spawn.x and pos.z == spawn.z and (pos.y == 4 or pos.y == 5) then return {name = "water"} end
return {name = "air"}
end
h.homes[p.name] = {x = spawn.x, y = spawn.y, z = spawn.z}
p.pos = {x = 100, y = 4, z = 100}
assert(hearth.go_home(p)); h.drain_zero()
assert(p.pos.x ~= spawn.x or p.pos.z ~= spawn.z)
assert(#h.kicks == 0)
end)
test("character join hook runs after session and base properties without taking form priority", function()
local h = harness(spawn); local p, meta = h.player()
local calls = 0
hearth.character = {on_join = function(player)
assert(player == p and hearth.sessions[p.name] and p.properties.visual == "mesh")
assert(p.properties.mesh == "hearth_person.b3d" and p.properties.eye_height == 1.55)
calls = calls + 1
player:set_properties({mesh = "hearth_person_compact.b3d", textures = {"fixture_appearance.png"}})
end}
h.callbacks.join(p)
assert(calls == 1 and p.properties.mesh == "hearth_person_compact.b3d")
assert(p.properties.eye_height == 1.55 and p.properties.visual_size.x == 1
and p.properties.collisionbox[5] == 1.75 and p.properties.hp_max == 20)
assert(meta:get_int("hearth:arrived") == 1 and #h.gui == 0)
hearth.has_location = false; core.is_singleplayer = function() return true end
for _, entry in ipairs(h.after) do if entry.delay == 1 then entry.fn() end end
assert(#h.gui == 1 and h.gui[1].kind == "location", "Location must retain join form priority")
end)
test("real character factory restores saved metadata during player initialization", function()
local h = harness(spawn); local p, meta = h.player()
core.register_on_player_receive_fields = function() end
core.register_chatcommand = function() end
hearth.character = dofile(root .. "character.lua")(hearth, core)
local d = hearth.character.defaults(); d.body = "broad"; d.skin = "umber"
meta.values["hearth:appearance"] = assert(hearth.character.encode(d))
h.callbacks.join(p)
assert(p.properties.mesh == "hearth_person_broad.b3d" and p.property_calls == 2)
assert(p.properties.textures[1]:find("hearth_char_skin_umber.png",1,true))
assert(p.properties.eye_height == 1.55 and p.properties.stepheight == 0.6)
assert(meta.writes == 0, "Joining with saved appearance must not rewrite metadata")
assert(#h.kicks == 0 and not hearth.character.state(p).warning)
end)
test("malformed saved world spawn fails module startup without overwrite", function()
local h = harness(spawn); h.world.values.spawn = "PRIVATE malformed"
local ok, message = pcall(dofile, target)
assert(not ok and not tostring(message):find("PRIVATE", 1, true))
assert(h.world.values.spawn == "PRIVATE malformed" and h.world.writes == 0); no_private(h)
end)
assert(total == 27, "Unexpected lifecycle regression count")
print("PASS " .. total .. " player lifecycle safety tests")