-- Standalone Lua 5.1+ scheduler regressions; arg[1] is runtime.lua. -- Lifecycle persistence and respawn callbacks are exercised by players_spec. local target = assert(arg[1], "runtime.lua path required") 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) local result = {}; for k, v in pairs(value) do result[k] = v end; return result end local function harness(map_limit) local h = {steps = {}, connected = {}, needs = {}, hud = {}, saves = {}, arrivals = {}, particles = {}, clocks = 0, mapgen_limit = map_limit} core = { register_globalstep = function(fn) h.steps[#h.steps + 1] = fn end, settings = {get = function(_, key) if key == "mapgen_limit" then return h.mapgen_limit end end}, registered_nodes = {ground = {walkable = true}, air = {walkable = false}, water = {walkable = false, groups = {water = 3}}}, get_node = function(pos) h.node_calls = (h.node_calls or 0) + 1 return {name = h.water and "water" or (pos.y < 4 and "ground" or "air")} end, get_connected_players = function() return h.connected end, find_node_near = function() return nil end, add_particlespawner = function(value) h.particles[#h.particles + 1] = value end, } core.get_node_or_nil = core.get_node vector = {offset = function(p, x, y, z) return {x = p.x + x, y = p.y + y, z = p.z + z} end} hearth = {players = {}, sessions = {}, weather = "clear", model = {}} hearth.movement = dofile(target:gsub("[^/\\]+$", "movement.lua")) hearth.clock_update = function() h.clocks = h.clocks + 1 end hearth.environment = function(pos) h.environment_calls = (h.environment_calls or 0) + 1 h.last_pos = pos return {temperature = 20, shelter = 0, heat = h.heat or 0, smoke = h.smoke, roof = h.roof} end hearth.model.needs = function(state, env, dt) h.needs[#h.needs + 1] = {state = state, env = env, dt = dt} local updated = copy(state); updated.food = state.food - dt updated.wet = math.max(0, state.wet + (env.drying and -1.5 or -0.35) * dt) return updated, h.danger or 0 end hearth.update_hud = function(p, env) h.hud[#h.hud + 1] = {player = p, env = env} end hearth.player_save = function(p) h.saves[#h.saves + 1] = p; return true end hearth.safe_arrival = function(p) h.arrivals[#h.arrivals + 1] = p; p.pos = {x = 4, y = 5, z = 8} end hearth.texture = function(value) return "texture:" .. value end function h.player(name) local meta = {get_int = function() return 0 end} local p = {name = name or "fixture", hp = 20, pos = {x = 0, y = 4, z = 0}, controls = {}, item = "", physics = {}, damage = {}, clouds = {}} function p:get_player_name() return self.name end function p:get_player_control() return self.controls end function p:get_wielded_item() return {get_name = function() return self.item end} end function p:get_pos() return self.pos end function p:get_velocity() return self.velocity or {x = 0, y = 0, z = 0} end function p:get_hp() return self.hp end function p:get_meta() return meta end function p:set_hp(value, reason) self.hp = value; self.damage[#self.damage + 1] = reason end function p:set_physics_override(value) self.physics[#self.physics + 1] = value end function p:set_clouds(value) self.clouds[#self.clouds + 1] = value end h.connected[#h.connected + 1] = p hearth.players[p.name] = {food = 85, water = 85, warmth = 80, wet = 10, stamina = 100} hearth.sessions[p.name] = {damage = 0, save = 0, ready = false} return p, hearth.players[p.name], hearth.sessions[p.name] end dofile(target) return h end local function feedback_harness() local h = harness(); local p, state, s = h.player() h.time, h.sounds, h.animations, h.fovs, h.messages, h.commands, h.rays = 0, {}, {}, {}, {}, {}, 0 core.get_us_time = function() return h.time * 1000000 end core.sound_play = function(name, spec, ephemeral) h.sounds[#h.sounds + 1] = {name = name, spec = spec}; assert(ephemeral) end core.register_chatcommand = function(name, def) h.commands[name] = def end core.get_player_by_name = function(name) if name == p.name then return p end end core.get_dig_params = function() return {diggable = h.suitable ~= false} end core.registered_nodes.ground.description = "Soil" core.raycast = function(start, finish, objects, liquids) h.rays = h.rays + 1 assert(not objects and not liquids and finish.z - start.z == 5) local used = false return function() if used or h.no_target then return end used = true; return {type = "node", under = {x = 0, y = 3, z = 0}} end end function p:get_look_dir() return {x = 0, y = 0, z = 1} end function p:get_properties() return {eye_height = 1.55} end function p:get_wielded_item() return {get_name = function() return self.item end, get_tool_capabilities = function() return {} end, get_wear = function() return 0 end} end function p:set_animation(range, speed, blend, loop) h.animations[#h.animations + 1] = {range = range, speed = speed, loop = loop} end function p:set_fov(fov, multiplier, transition) assert(not multiplier and transition == 0.2); h.fovs[#h.fovs + 1] = fov end function p:hud_add(def) h.messages[#h.messages + 1] = def.text; return 42 end function p:hud_change(id, field, value) assert(id == 42); if field == "text" then h.messages[#h.messages + 1] = value end end dofile(target:gsub("[^/\\]+$", "feedback.lua")) function h.tick(dt) h.time = h.time + dt; hearth.feedback_step(p, dt) end return h, p, state, s end test("scheduler registers two callbacks and clocks advance without connected players", function() local h = harness(); assert(#h.steps == 2) h.steps[1](9); assert(h.clocks == 0) h.steps[1](1); assert(h.clocks == 1) h.steps[1](100); assert(h.clocks == 2, "Clock must not replay unbounded catch-up updates") assert(#h.needs == 0 and #h.saves == 0) end) test("waiting arrival never evaluates needs damages saves HUD or fall recovery", function() local h = harness(); local p, state, session = h.player(); session.waiting = true state.food = 0; p.pos.y = -101; h.danger = 100; h.smoke = true for _ = 1, 5 do h.steps[1](1) end assert(hearth.players[p.name] == state and state.food == 0 and p.hp == 20) assert(session.damage == 0 and session.save == 0 and h.environment_calls == nil) assert(#h.needs == 0 and #h.saves == 0 and #h.hud == 0 and #h.arrivals == 0) session.waiting = false; p.pos.y = 4; h.danger = 0; h.smoke = false; h.steps[1](1) assert(#h.needs == 1 and #h.hud == 1 and session.save == 1) end) test("subsecond controls gate sprint brace and avoid redundant physics writes", function() local h = harness(); local p, state = h.player() p.controls = {aux1 = true, up = true}; h.steps[1](0.1); assert(p.physics[1].speed == 1.3) h.steps[1](0.1); assert(p.physics[2].speed == 1.45) h.steps[1](0.1); assert(#p.physics == 2) p.item = "hearth:spear"; p.controls.RMB = true for _ = 1, 3 do h.steps[1](0.1) end assert(math.abs(p.physics[#p.physics].speed - 0.6) < 0.001) assert(hearth.sessions[p.name].blocking and not hearth.sessions[p.name].sprinting) p.item = ""; p.controls.sneak = true for _ = 1, 2 do h.steps[1](0.1) end assert(p.physics[#p.physics].speed == 1) p.controls.sneak = false; state.stamina = 8; h.steps[1](0.1) state.stamina = 31; h.steps[1](0.01); assert(not hearth.sessions[p.name].sprinting) assert(p.physics[#p.physics].jump == nil and p.physics[#p.physics].gravity == nil and #h.needs == 0) end) test("one Hz needs caps elapsed step at two seconds and applies bounded heat drying", function() local h = harness(); local p, state = h.player(); p.controls = {aux1 = true, up = true} h.heat = 3; h.wet = 10 h.steps[1](0.4); assert(#h.needs == 0) h.steps[1](0.6) assert(#h.needs == 1 and h.needs[1].dt == 1 and h.needs[1].state == state and h.needs[1].env.sprinting) assert(hearth.players[p.name] ~= state and hearth.players[p.name].wet == 8.5 and state.wet == 10) h.steps[1](100) assert(#h.needs == 2 and h.needs[2].dt == 2 and hearth.players[p.name].food == 82) assert(hearth.players[p.name].wet == 5.5 and hearth.sessions[p.name].save == 3) hearth.players[p.name].wet = 1 h.heat = 20; h.steps[1](1); assert(hearth.players[p.name].wet == 0) end) test("danger smoke accumulation damages at threshold with at most one hit per capped step", function() local h = harness(); local p, _, session = h.player(); h.danger = 1; h.smoke = true h.steps[1](1); assert(p.hp == 20 and session.damage == 2) h.steps[1](1); assert(p.hp == 19 and session.damage == 0 and #p.damage == 1) assert(p.damage[1].type == "set_hp" and p.damage[1].from == "mod") h.danger = 100; h.steps[1](100) assert(p.hp == 18 and #p.damage == 2 and session.damage == 0 and h.needs[3].dt == 2) end) test("autosave uses bounded active simulation time and resets its interval", function() local h = harness(); local p, _, session = h.player() for _ = 1, 9 do h.steps[1](1) end assert(#h.saves == 0 and session.save == 9) h.steps[1](1); assert(#h.saves == 1 and h.saves[1] == p and session.save == 0) h.steps[1](100); assert(#h.saves == 1 and session.save == 2) for _ = 1, 8 do h.steps[1](1) end assert(#h.saves == 2 and session.save == 0 and #h.hud == 19) end) test("fall recovery uses mapgen_limit plus margin and does not teleport valid caves", function() local h = harness(); local p = h.player(); p.pos.y = -100 h.steps[1](1); assert(#h.arrivals == 0) p.pos.y = -544; h.steps[1](1); assert(#h.arrivals == 0) p.pos.y = -545; h.steps[1](1) assert(#h.arrivals == 1 and h.arrivals[1] == p and p.pos.y == 5) h = harness(1000); p = h.player(); p.pos.y = -1032; h.steps[1](1) assert(#h.arrivals == 0); p.pos.y = -1033; h.steps[1](1); assert(#h.arrivals == 1) end) test("dead and left players skip needs while a fresh respawn or rejoin remains schedulable", function() local h = harness(); local p, _, old_session = h.player(); p.hp = 0 h.steps[1](1); assert(#h.needs == 0 and #h.hud == 0 and old_session.save == 0) -- Engine may still enumerate a just-left player; lifecycle removes both maps. hearth.players[p.name], hearth.sessions[p.name] = nil, nil h.steps[1](1); assert(#h.needs == 0 and #h.saves == 0) h.connected = {}; local q, fresh, session = h.player(p.name) h.steps[1](1) assert(h.needs[1].state == fresh and #h.hud == 1 and session.save == 1 and old_session.save == 0) assert(q.hp == 20 and hearth.players[q.name].food == 84) end) test("autosave teardown is safe for the current tick later players and subsequent ticks", function() local h = harness(); local p, _, session = h.player("one"); local q = h.player("two") session.save = 9 hearth.player_save = function(player) h.saves[#h.saves + 1] = player hearth.players[player.name], hearth.sessions[player.name] = nil, nil return false end h.steps[1](1) assert(#h.saves == 1 and h.saves[1] == p and not hearth.sessions[p.name] and not hearth.players[p.name]) assert(hearth.players[q.name].food == 84 and #h.needs == 2) h.steps[1](1) assert(#h.needs == 3 and #h.saves == 1 and hearth.players[q.name].food == 83) end) test("rain callback is throttled player local roof aware and independent of needs", function() local h = harness(); local p = h.player(); hearth.weather = "rain" h.steps[2](2); assert(#p.clouds == 0 and #h.particles == 0) h.steps[2](1) assert(p.clouds[1].density == 0.65 and #h.particles == 1) local rain = h.particles[1] assert(rain.playername == p.name and rain.amount == 80 and rain.time == 3) assert(rain.minpos.x == -7 and rain.minpos.y == 11 and rain.maxpos.z == 7) assert(rain.collisiondetection and rain.collision_removal and #h.needs == 0) h.roof = true; h.steps[2](3); assert(#h.particles == 1) hearth.weather = "clear"; h.steps[2](3) assert(#h.particles == 1 and p.clouds[3].density == 0.25) end) test("continuous stamina is not drained twice by needs and sprint food costs remain", function() local h = harness(); local p, state = h.player(); p.controls = {aux1 = true, up = true} local original = hearth.model.needs hearth.model.needs = function(s, env, dt) local updated, danger = original(s, env, dt) updated.stamina = s.stamina - 13 * dt return updated, danger end h.steps[1](0.5); assert(math.abs(state.stamina - 98.7) < 0.001) h.steps[1](0.5) assert(math.abs(hearth.players[p.name].stamina - 97.4) < 0.001) assert(#h.needs == 1 and h.needs[1].env.sprinting) end) test("water velocity and lifecycle gate movement and optional per frame feedback", function() local h = harness(); local p, _, s = h.player(); local calls = 0 hearth.feedback_step = function(player, dt) assert(player == p and dt == 0.1); calls = calls + 1 end p.controls = {aux1 = true, up = true}; h.water = true; h.steps[1](0.1) assert(s.swimming and not s.sprinting and calls == 1) h.water = false; p.velocity = {x = 3, y = -5, z = 0}; h.steps[1](0.1) assert(not s.grounded and not s.sprinting and s.moving and calls == 2) s.waiting = true; local nodes = h.node_calls; h.steps[1](0.1) assert(h.node_calls == nodes and calls == 2) s.waiting = false; p.hp = 0; h.steps[1](0.1); assert(calls == 2) p.hp = 20; hearth.players[p.name] = nil; h.steps[1](0.1); assert(calls == 2) end) test("feedback animates only state transitions and never imposes default FOV", function() local h, p, _, s = feedback_harness() h.tick(0.01); h.tick(0.01) assert(#h.animations == 1 and h.animations[1].range.x == 0 and #h.fovs == 0) s.moving = true; h.tick(0.01); assert(h.animations[2].range.x == 20) s.sprinting = true; h.tick(0.01); assert(h.animations[3].range.x == 40) s.blocking = true; h.tick(0.01); assert(h.animations[4].range.x == 70) s.swimming = true; h.tick(0.01); assert(h.animations[5].range.x == 80) s.swimming, s.blocking, s.moving = false, false, false; p.controls.LMB = true h.tick(0.01); assert(h.animations[6].range.x == 60 and not h.animations[6].loop) h.tick(0.4); assert(h.animations[7].range.x == 0) assert(h.commands.comfort.func(p.name, "fov 75")); h.tick(0.01); h.tick(0.01) assert(#h.fovs == 1 and h.fovs[1] == 75) assert(not h.commands.comfort.func(p.name, "fov 999")) assert(h.commands.comfort.func(p.name, "default")); h.tick(0.01) assert(#h.fovs == 2 and h.fovs[2] == 0) end) test("feedback lazily caches targeting and expires bounded notifications monotonically", function() local h, p, _, s = feedback_harness() h.tick(0.01); assert(s.feedback_hud == 42 and #h.messages == 1 and h.rays == 1) for _ = 1, 5 do h.tick(0.01) end assert(h.rays == 1 and #h.messages == 1) h.suitable = false; h.tick(0.1) assert(h.messages[#h.messages]:find("Different tool needed", 1, true)) hearth.feedback(p, string.rep("x", 300) .. "\n", "warning") assert(#s.feedback_text == 200 and s.feedback_until > h.time) h.tick(0.1); assert(h.messages[#h.messages] == s.feedback_text) local rays = h.rays; h.tick(0.1); assert(h.rays == rays) h.no_target = true; h.tick(3.1); assert(h.messages[#h.messages] == "") assert(#h.sounds == 0) end) test("footfalls require grounded distance and cadence with one landing not per frame audio", function() local h, p, _, s = feedback_harness() s.grounded, s.moving = true, true; h.tick(0.1) for _ = 1, 5 do h.tick(0.1) end; assert(#h.sounds == 0) for _ = 1, 5 do p.pos.x = p.pos.x + 0.4; h.tick(0.1) end assert(#h.sounds == 1 and h.sounds[1].name == "hearth_step_soil") assert(h.sounds[1].spec.pos == p.pos and h.sounds[1].spec.max_hear_distance == 12) s.grounded = false; p.velocity = {x = 2, y = -5, z = 0} for _ = 1, 5 do p.pos.x = p.pos.x + 0.4; h.tick(0.1) end assert(#h.sounds == 1) s.grounded = true; p.velocity.y = 0; h.tick(0.1); h.tick(0.1) assert(#h.sounds == 2 and h.sounds[2].name == "hearth_land") p.pos.x = p.pos.x + 100; h.tick(0.1); assert(#h.sounds == 2) for index, material in ipairs({"wood", "stone", "water"}) do core.registered_nodes.ground.groups = {[material] = 1} for _ = 1, 5 do p.pos.x = p.pos.x + 0.4; h.tick(0.1) end assert(#h.sounds == index + 2 and h.sounds[#h.sounds].name == "hearth_step_" .. material) end end) assert(total == 15, "Unexpected scheduler regression count") print("PASS " .. total .. " runtime scheduler safety tests")