Files
2026-11-03 12:41:09 -05:00

96 lines
5.1 KiB
Lua
Executable File

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