-- 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")