275 lines
18 KiB
Lua
Executable File
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")
|