-- Standalone Lua 5.1+; also runs in the engine's isolated unit environment. local M = dofile(assert(arg[1], "combat.lua path required")) local count = 0 local function test(name, fn) local ok, err = pcall(fn); assert(ok, name .. ": " .. tostring(err)); count = count + 1 end local function context() return {live = true, hp = 20, state = {stamina = 100}, session = {}, now = 10, weapon = "hearth:axe", allowed = true, clear = true, origin = {x = 0, y = 0, z = 0}, eye = {x = 0, y = 1.55, z = 0}, target = {x = 0, y = 1.55, z = 2}, look = {x = 0, y = 0, z = 1}} end local function denied(c, reason) local hit, why = M.valid_attack(c); assert(not hit and why == reason, tostring(why)) end test("weapon reach damage cadence cost and durability are distinct", function() local c = context() for _, spec in ipairs({{"hearth:axe", 2.5, 4, 1.1, 12}, {"hearth:knife", 1.8, 3, 0.7, 8}, {"hearth:spear", 3.4, 5, 1.2, 14}}) do c.weapon, c.target.z = spec[1], spec[2] local a = assert(M.valid_attack(c)); assert(a.damage == spec[3] and a.cadence == spec[4] and a.stamina == spec[5] and a.wear > 0) c.target.z = spec[2] + 0.001; denied(c, "reach") a.damage = 999; c.target.z = spec[2]; assert(M.valid_attack(c).damage == spec[3]) end end) test("head center ray obstruction and full vertical distance", function() local c = context(); c.clear = false; denied(c, "obstructed") c.clear = nil; denied(c, "obstructed") c.clear = true; c.target.y = 5; denied(c, "reach") c.target.y, c.target.z = 3.55, 0; c.look = {x = 0, y = 1, z = 0}; assert(M.valid_attack(c)) end) test("aim cone is normalized three dimensional and required even nearby", function() local c = context(); c.target.z = 0.2 c.look = {x = 0, y = 0, z = -1}; denied(c, "aim") c.look = {x = math.sqrt(1 - 0.35^2), y = 0, z = 0.35}; assert(M.valid_attack(c)) c.look.z = 0.349; denied(c, "aim") c.look = {x = 0, y = 100, z = 0}; denied(c, "aim") c.look = {x = 0, y = 0, z = 0}; denied(c, "aim") end) test("one session deadline survives weapon swap and target swap", function() local c = context(); local a = assert(M.valid_attack(c)) c.session.attack_ready_at, c.session.combat_now = c.now + a.cadence, c.now c.weapon, c.target.z, c.now = "hearth:knife", 1, 10.8; denied(c, "cadence") c.weapon, c.target.z = "hearth:spear", 3; denied(c, "cadence") c.now = 11.1; assert(M.valid_attack(c)) c.now = 9; denied(c, "clock") end) test("exhaustion never regenerates on rejected or accepted validation", function() local c = context(); c.state.stamina = 11.99 for _ = 1, 100 do denied(c, "stamina") end assert(c.state.stamina == 11.99 and c.session.attack_ready_at == nil) c.state.stamina = 12; assert(M.valid_attack(c)); assert(c.state.stamina == 12) c.state.stamina = 0 / 0; denied(c, "stamina") end) test("live state session dead waiting and explicit claims fail closed", function() local c = context(); c.live = false; denied(c, "inactive") c.live, c.hp = true, 0; denied(c, "inactive") c.hp, c.session.waiting = 20, true; denied(c, "inactive") c.session, c.state = {}, nil; denied(c, "inactive") c.state, c.session = {stamina = 100}, nil; denied(c, "inactive") c.session, c.allowed = {}, false; denied(c, "claim") c.allowed = nil; denied(c, "claim") end) test("unsupported tools malformed positions and clocks cannot hit", function() local c = context(); c.weapon = "hearth:pick"; denied(c, "weapon") c.weapon, c.now = "hearth:axe", math.huge; denied(c, "clock") c.now, c.target.x = 10, 0 / 0; denied(c, "position") end) test("short sprint displacement accepted but recent teleport rejected", function() local c = context(); c.session.combat_sample = M.sample(nil, c.origin, 9.9) c.origin.x = 0.8; assert(M.valid_attack(c)) c.origin.x = 5; denied(c, "movement") local previous = c.session.combat_sample local updated = M.sample(previous, c.origin, 10) assert(updated ~= previous and previous.pos.x == 0 and updated.blocked_until == 10.5) c.session.combat_sample = updated; denied(c, "movement") c.now = 10.5; assert(M.valid_attack(c)) assert(M.sample(updated, c.origin, 9) == updated) end) test("round cooldown is independent of attack cooldown and entity reset", function() local c = context(); c.session.spar_ready_at = 13 denied(c, "cadence"); c.now = 13; assert(M.valid_attack(c)) end) test("brace requires fresh RMB spear facing and eight stamina not cached blocking", function() local c = context(); c.weapon, c.controls = "hearth:spear", {RMB = true} c.state.stamina = 8; assert(M.valid_guard(c)); assert(c.state.stamina == 8) c.session.blocking, c.controls.RMB = true, false; assert(not M.valid_guard(c)) c.controls.RMB, c.look.z = true, -1; assert(not M.valid_guard(c)) c.look = {x = math.sqrt(1 - 0.4^2), y = 0, z = 0.4}; assert(M.valid_guard(c)) c.look.z = 0.399; assert(not M.valid_guard(c)) c.look = {x = 0, y = 0, z = 1}; c.state.stamina = 7.99; assert(not M.valid_guard(c)) c.state.stamina, c.weapon = 8, "hearth:knife"; assert(not M.valid_guard(c)) c.weapon, c.session.waiting = "hearth:spear", true; assert(not M.valid_guard(c)) c.session.waiting, c.hp = false, 0; assert(not M.valid_guard(c)) end) print("PASS " .. count .. " combat checks")