300 lines
16 KiB
Lua
Executable File
300 lines
16 KiB
Lua
Executable File
-- 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")
|