410 lines
22 KiB
Lua
Executable File
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")
|