Files
last-hearth/tests/encounters_spec.lua
2026-11-03 12:41:09 -05:00

275 lines
18 KiB
Lua
Executable File

-- Engine-independent adapter tests. Real pure model; complete isolated core mocks.
local target = assert(arg[1], "encounters.lua path required")
local inherited_combat = hearth and hearth.combat
local combat = inherited_combat or dofile(target:gsub("[^/\\]+$", "combat.lua"))
local total = 0
local function test(name, fn)
local old_core, old_hearth = core, hearth
local ok, err = pcall(fn); core, hearth = old_core, old_hearth
assert(ok, name .. ": " .. tostring(err)); total = total + 1
end
local function harness()
local h = {time = 10, entities = {}, commands = {}, steps = {}, players = {}, objects = {},
sounds = {}, messages = {}, rays = {}, scans = 0, nodes = 0, single = true}
core = {
get_us_time = function() return h.time * 1000000 end,
register_entity = function(name, def) h.entities[name] = def end,
register_chatcommand = function(name, def) h.commands[name] = def end,
register_globalstep = function(fn) h.steps[#h.steps + 1] = fn end,
get_player_by_name = function(name) return h.players[name] end,
get_connected_players = function() local out = {}; for _, p in pairs(h.players) do out[#out + 1] = p end; return out end,
is_singleplayer = function() return h.single end,
settings = {get = function(_, key) assert(key == "hearth_environment"); return h.deployment end},
check_player_privs = function(_, privs) assert(privs.server); return h.privileged == true end,
dir_to_yaw = function(v) return (math.atan2 or math.atan)(-v.x, v.z) end,
is_protected = function() return h.protected == true end,
line_of_sight = function(a, b, step)
assert(step == 0.1); h.rays[#h.rays + 1] = {a = a, b = b}
return not h.obstructed and (not h.ray_test or h.ray_test(a, b))
end,
registered_nodes = {air = {walkable = false, liquidtype = "none"}, ground = {walkable = true},
water = {walkable = false, liquidtype = "source"}, stone = {walkable = true}},
get_node_or_nil = function(pos)
h.nodes = h.nodes + 1
if h.unloaded then return nil end
if h.node_test then return h.node_test(pos) end
return {name = h.liquid and "water" or (pos.y < 0 and "ground" or "air")}
end,
get_objects_inside_radius = function(_, radius) assert(radius == 30); h.scans = h.scans + 1; return h.objects end,
sound_play = function(name, spec, ephemeral) assert(ephemeral and spec.pos); h.sounds[#h.sounds + 1] = name end,
}
hearth = {combat = combat, players = {}, sessions = {},
claim_allowed = function(pos, name) return not h.denied and (not h.claim_test or h.claim_test(pos, name)) end,
texture = function(color) return "texture:" .. color end,
feedback = function(p, msg, kind) h.messages[#h.messages + 1] = {p = p, msg = msg, kind = kind} end}
function h.player(name)
local p = {name = name or "alice", hp = 20, pos = {x = 0, y = 0, z = 0},
look = {x = 0, y = 0, z = 1}, controls = {}, item = "hearth:axe", wear = 0, damage = 0}
function p:is_player() return true end
function p:get_player_name() return self.name end
function p:get_pos() return self.pos end
function p:get_hp() return self.hp end
function p:get_look_dir() return self.look end
function p:get_properties() return {eye_height = 1.55} end
function p:get_player_control() return self.controls end
function p:get_wielded_item()
return {get_name = function() return p.item end, add_wear = function(_, n) p.wear = p.wear + n end}
end
function p:set_wielded_item(stack) assert(stack.get_name); self.wield_updates = (self.wield_updates or 0) + 1 end
function p:set_hp(n, reason) assert(reason.type == "set_hp" and reason.from == "mod"); self.hp = n; self.damage = self.damage + 1 end
h.players[p.name] = p; hearth.players[p.name] = {stamina = 100}; hearth.sessions[p.name] = {}
return p, hearth.players[p.name], hearth.sessions[p.name]
end
function h.entity(name, pos)
local def = assert(h.entities[name]); local e = setmetatable({name = name}, {__index = def})
local o = {pos = pos or {x = 0, y = 0, z = 1.5}, velocity = {x = 0, y = 0, z = 0}, properties = {}, animations = {}}
function o:get_pos() return self.pos end
function o:get_velocity() return self.velocity end
function o:set_velocity(v) assert(math.sqrt(v.x^2 + v.z^2) <= 2.20001); self.velocity = v end
function o:set_yaw(yaw) self.yaw = yaw end
function o:set_acceleration(v) self.acceleration = v end
function o:set_armor_groups(v) assert(v.immortal == 1) end
function o:set_properties(v) for k, value in pairs(v) do self.properties[k] = value end end
function o:set_animation(range, speed, blend, loop) self.animations[#self.animations + 1] = {range = range, speed = speed, loop = loop} end
function o:get_luaentity() return e end
e.object = o; h.objects[#h.objects + 1] = o; e:on_activate("old target data ignored")
return e
end
core.add_entity = function(pos, name) local e = h.entity(name, pos); return e.object end
dofile(target)
function h.tick(e, dt, physics)
h.time = h.time + dt; e:on_step(dt)
if physics then
local p, v = e.object.pos, e.object.velocity
p.x, p.z = p.x + v.x * math.min(dt, 0.1), p.z + v.z * math.min(dt, 0.1)
end
end
function h.counter(e) h.tick(e, 0.75); assert(e.phase == "strike") end
return h
end
test("stationary identity persists and moving model is ephemeral animated B3D", function()
local h = harness(); local a, b = h.entities["hearth:practice"], h.entities["hearth:sparring"]
assert(a.initial_properties.static_save and not b.initial_properties.static_save)
assert(b.initial_properties.mesh == "hearth_person.b3d" and b.initial_properties.textures[1] == "hearth_person.png")
local e = h.entity("hearth:practice"); e.health, e.target = 0, "stale"; e:on_activate("bad json")
assert(e.health == 30 and e.target == nil and e.phase == "idle" and e.name == "hearth:practice")
end)
test("practice local-only spar staging requires server privilege production always denies", function()
local h = harness(); h.player()
h.single, h.deployment = false, "staging"
assert(not h.commands.practice.func("alice") and not h.commands.spar.func("alice"))
h.privileged = true; assert(h.commands.spar.func("alice")); assert(#h.objects == 1)
assert(not h.commands.spar.func("alice"))
h = harness(); h.player(); h.deployment, h.privileged = "production", true
assert(not h.commands.spar.func("alice") and not h.commands.practice.func("alice") and #h.objects == 0)
h.single, h.deployment = false, nil; assert(not h.commands.spar.func("alice"))
end)
test("local practice preserved nearby duplicate refusal and no tick scans", function()
local h = harness(); h.player(); assert(h.commands.practice.func("alice")); assert(not h.commands.practice.func("alice"))
local scans = h.scans; h.tick(h.objects[1]:get_luaentity(), 0.1); assert(h.scans == scans)
end)
test("spawn rejects protected claims unloaded liquid obstructed and waiting arrival", function()
for _, flag in ipairs({"denied", "protected", "unloaded", "liquid", "obstructed"}) do
local h = harness(); h.player(); h[flag] = true; assert(not h.commands.spar.func("alice") and #h.objects == 0)
end
local h = harness(); local _, _, s = h.player(); s.waiting = true; assert(not h.commands.spar.func("alice"))
end)
test("confirmed hit spends exact stamina wears current inventory and ignores client damage", function()
local h = harness(); local p, state, s = h.player(); local e = h.entity("hearth:practice")
assert(e:on_punch(p, 999, {damage_groups = {fleshy = 999}}, nil, 999) == true)
assert(e.health == 26 and state.stamina == 88 and p.wear == 655 and p.wield_updates == 1)
assert(s.attack_ready_at == 11.1 and s.strike_until > h.time and s.spar_entity == e)
assert(h.sounds[1] == "hearth_swing_axe" and h.sounds[2] == "hearth_impact")
local ray = h.rays[1]; assert(ray.a.y == 1.55 and ray.b.y == 0.9)
end)
test("weapon swap other targets and entity activation never reset session cadence", function()
local h = harness(); local p, state, s = h.player(); local a, b = h.entity("hearth:practice"), h.entity("hearth:practice")
a:on_punch(p); local ready = s.attack_ready_at; p.item = "hearth:knife"
a:on_activate(""); b:on_punch(p); assert(b.health == 30 and state.stamina == 88 and s.attack_ready_at == ready)
-- Simulate entity removal releasing its lock, without touching session cadence.
s.spar_entity = nil; h.time = 10.8; b:on_punch(p); assert(b.health == 30)
h.time = ready; b:on_punch(p); assert(b.health == 27 and state.stamina == 80)
end)
test("invalid hits never wear spend regenerate start a round or feedback spam", function()
for _, mode in ipairs({"stamina", "waiting", "dead", "offline", "claim", "wall", "aim", "reach", "state", "session"}) do
local h = harness(); local p, state, s = h.player(); local e = h.entity("hearth:practice")
if mode == "stamina" then state.stamina = 1 elseif mode == "waiting" then s.waiting = true
elseif mode == "dead" then p.hp = 0 elseif mode == "offline" then h.players.alice = nil
elseif mode == "claim" then h.denied = true elseif mode == "wall" then h.obstructed = true
elseif mode == "aim" then p.look.z = -1 elseif mode == "reach" then e.object.pos.y = 5
elseif mode == "state" then hearth.players.alice = nil elseif mode == "session" then hearth.sessions.alice = nil end
local before = state.stamina; for _ = 1, 20 do assert(e:on_punch(p)) end
assert(e.health == 30 and e.target == nil and p.wear == 0 and state.stamina == before and #h.messages == 0, mode)
end
end)
test("independent movement samples reject sprint-impossible recent displacement", function()
local h = harness(); local p, _, s = h.player(); h.steps[1](0.1)
p.pos.z = 8; local e = h.entity("hearth:practice", {x = 0, y = 0, z = 9.5})
h.time = h.time + 0.1; e:on_punch(p); assert(e.health == 30)
h.steps[1](0.1); assert(s.combat_sample.blocked_until > h.time); e:on_punch(p); assert(e.health == 30)
end)
test("fresh facing spear RMB guard ignores stale session blocking and weapon swap", function()
for _, mode in ipairs({"guard", "back", "released", "swapped", "tired"}) do
local h = harness(); local p, state, s = h.player(); p.item = "hearth:spear"
local e = h.entity("hearth:practice"); e:on_punch(p); s.blocking = true; p.controls.RMB = true
if mode == "back" then p.look.z = -1 elseif mode == "released" then p.controls.RMB = false
elseif mode == "swapped" then p.item = "hearth:axe" elseif mode == "tired" then state.stamina = 7 end
local before = state.stamina; h.counter(e)
assert(p.hp == (mode == "guard" and 19 or 17), mode)
assert(state.stamina == before - (mode == "guard" and 8 or 0), mode)
end
end)
test("backstep lateral vertical and obstruction evade counter", function()
for _, mode in ipairs({"back", "side", "vertical", "wall"}) do
local h = harness(); local p = h.player(); local e = h.entity("hearth:practice"); e:on_punch(p)
if mode == "back" then p.pos.z = -2 elseif mode == "side" then p.pos.x = 3
elseif mode == "vertical" then p.pos.y = 4 else h.obstructed = true end
h.counter(e); assert(p.hp == 20, mode)
end
end)
test("committed windup cannot be reset or cancelled even by finishing hit", function()
local h = harness(); local p, state = h.player(); p.item = "hearth:knife"
local e = h.entity("hearth:practice"); e.health = 6; e:on_punch(p); local deadline = e.deadline
h.time = h.time + 0.7; e:on_punch(p)
assert(e.health == 0 and e.round_complete and e.phase == "windup" and e.deadline == deadline)
local before = state.stamina; e:on_punch(p); assert(state.stamina == before)
h.tick(e, 0.05); assert(e.phase == "strike" and p.hp == 17)
h.tick(e, 0.12); assert(e.phase == "recover"); h.tick(e, 0.91)
assert(e.phase == "cooldown" and hearth.sessions.alice.spar_ready_at == h.time + 3)
end)
test("round rest persists across reset other targets spawn attempts and no regen", function()
local h = harness(); local p, state, s = h.player(); local e = h.entity("hearth:practice")
e.health = 4; e:on_punch(p); h.counter(e); h.tick(e, 0.13); h.tick(e, 0.91)
local ready, stamina = s.spar_ready_at, state.stamina; assert(math.abs(ready - h.time - 3) < 0.000001)
e:on_activate(""); local other = h.entity("hearth:practice"); other:on_punch(p)
assert(other.health == 30 and not h.commands.spar.func("alice") and state.stamina == stamina)
h.time = ready + 0.000001; other:on_punch(p); assert(other.health == 26 and s.spar_ready_at == ready)
end)
test("only consenting participant can be hurt never bystanders or PvP", function()
local h = harness(); local p = h.player(); local other = h.player("bob"); local e = h.entity("hearth:practice")
e:on_punch(p); e:on_punch(other); assert(e.health == 26 and e.target == "alice")
h.counter(e); assert(p.hp == 17 and other.hp == 20 and other.damage == 0)
h = harness(); p = h.player(); p.hp = 3; e = h.entity("hearth:practice"); e:on_punch(p); h.counter(e); assert(p.hp == 2)
end)
test("death disconnect waiting or claim denial disarms without damage", function()
for _, mode in ipairs({"death", "offline", "waiting", "claim"}) do
local h = harness(); local p, _, s = h.player(); local e = h.entity("hearth:practice"); e:on_punch(p)
if mode == "death" then p.hp = 0 elseif mode == "offline" then h.players.alice = nil
elseif mode == "waiting" then s.waiting = true else h.denied = true end
h.tick(e, 1); assert(e.target == nil and e.phase == "cooldown" and p.damage == 0)
end
end)
test("moving opt in approaches at bounded speed perception five Hz and locks owner", function()
local h = harness(); local p = h.player(); local other = h.player("bob"); assert(h.commands.spar.func("alice"))
local e = h.objects[1]:get_luaentity(); assert(e.owner == "alice" and e.phase == "approach")
local before = h.nodes; h.tick(e, 0.05, true); assert(h.nodes == before)
for _ = 1, 3 do h.tick(e, 0.05, true) end
assert(e.object.velocity.z < 0 and math.abs(e.object.velocity.z) <= 2.2)
other.pos.z = 2; other.look.z = 1; e:on_punch(other); assert(e.health == 30)
for _ = 1, 30 do h.tick(e, 0.05, true); if e.phase == "windup" then break end end
assert(e.phase == "windup" and e.object.velocity.z == 0 and h.scans == 0)
local f = e.object.animations[#e.object.animations]; assert(f.range.x == 70 and f.range.y == 79)
h.counter(e); f = e.object.animations[#e.object.animations]; assert(f.range.x == 60 and not f.loop and p.hp == 17)
end)
test("terrain side avoidance loaded dry ground requirement and bounded stuck exit", function()
local h = harness(); h.player(); assert(h.commands.spar.func("alice")); local e = h.objects[1]:get_luaentity()
h.ray_test = function(a, b) return math.abs(b.x - a.x) > 0.1 end
for _ = 1, 2 do h.tick(e, 0.1) end
assert(math.abs(e.object.velocity.x) == 2.2 and e.object.velocity.z == 0)
-- No physics movement: repeated side attempts must terminate, not spin forever.
for _ = 1, 40 do h.tick(e, 0.1) end
assert(e.phase == "cooldown" and e.target == nil)
for _, flag in ipairs({"unloaded", "liquid"}) do
h = harness(); h.player(); assert(h.commands.spar.func("alice")); e = h.objects[1]:get_luaentity(); h[flag] = true
for _ = 1, 2 do h.tick(e, 0.1) end
assert(e.object.velocity.x == 0 and e.object.velocity.z == 0)
end
end)
test("leash disconnect deactivate and huge dt never pursue or catch up damage", function()
local h = harness(); local p, _, s = h.player(); assert(h.commands.spar.func("alice")); local e = h.objects[1]:get_luaentity()
p.pos.z = -10; h.tick(e, 100, true); assert(e.phase == "cooldown" and e.target == nil and p.damage == 0)
assert(s.spar_entity == nil and s.spar_ready_at > h.time)
h = harness(); p = h.player(); assert(h.commands.spar.func("alice")); e = h.objects[1]:get_luaentity()
h.players.alice = nil; h.tick(e, 0.1); assert(e.target == nil and e.object.velocity.z == 0)
h = harness(); p, _, s = h.player(); assert(h.commands.spar.func("alice")); e = h.objects[1]:get_luaentity()
e:on_deactivate(); assert(s.spar_entity == nil and s.spar_ready_at > h.time)
end)
test("optional feedback adapter sounds explicit names no chat fallback", function()
local h = harness(); local p = h.player(); hearth.feedback = nil
local calls = {}; hearth.feedback_sound = function(name, player, gain) assert(player == p and gain == 0.25); calls[#calls + 1] = name end
local e = h.entity("hearth:practice"); e:on_punch(p); h.counter(e)
assert(#h.messages == 0 and #h.sounds == 0 and calls[1] == "hearth_swing_axe" and calls[3] == "hearth_impact")
end)
test("unowned moving developer entity is inert and distant client punches do not trace world", function()
local h = harness(); local p = h.player(); local e = h.entity("hearth:sparring")
e:on_punch(p); assert(e.health == 30 and e.target == nil)
e = h.entity("hearth:practice", {x = 0, y = 0, z = 10000})
e:on_punch(p); assert(e.health == 30 and #h.rays == 0)
end)
test("moving rest and reopt in reset stuck memory but not player attack deadlines", function()
local h = harness(); local p, _, s = h.player(); assert(h.commands.spar.func("alice"))
local e = h.objects[1]:get_luaentity()
for _ = 1, 40 do h.tick(e, 0.1) end
assert(e.phase == "cooldown" and e.stuck > 3)
h.tick(e, 3.01); assert(e.phase == "idle")
p.pos.z = 1.5; p.look.z = 1; e:on_punch(p)
assert(e.target == "alice" and e.stuck == 0 and e.last_ai_pos == nil and s.attack_ready_at > h.time)
assert(not h.commands.spar.func("alice") and #h.objects == 1)
end)
test("deactivation clears consent and preserves rest without touching removed object", function()
local h = harness(); local _, _, s = h.player(); assert(h.commands.spar.func("alice"))
local e = h.objects[1]:get_luaentity()
e.object.get_velocity = function() error("removed ObjectRef accessed") end
e.object.set_properties = function() error("removed ObjectRef accessed") end
e:on_deactivate(); assert(s.spar_entity == nil and s.spar_ready_at > h.time)
end)
test("knife and spear consume durability only for confirmed in-reach hits", function()
for _, spec in ipairs({{"hearth:knife", 3, 8, 437}, {"hearth:spear", 5, 14, 546}}) do
local h = harness(); local p, state = h.player(); p.item = spec[1]
local e = h.entity("hearth:practice"); e:on_punch(p)
assert(e.health == 30 - spec[2] and state.stamina == 100 - spec[3] and p.wear == spec[4])
e:on_punch(p); assert(p.wear == spec[4] and p.wield_updates == 1)
end
end)
print("PASS " .. total .. " encounters checks")