DEMI-PROJECT-PACKAGE {"files":[{"hash":"sha256:073b343e7a9e027d7ea80eb04dc21ef05dcc070e5cbde19947f0d0c5c8afc5ee","path":"README.md","size":173},{"hash":"sha256:044621b36d187c0d8aff9a9f958093b08b5ddb3dc462f67b05219bdd83bb80e4","path":"scripts/demi/gameplay/health.lua","size":2766},{"hash":"sha256:be86bb140b7fe0ce7269e7185be6b9861a7cdca7fe1e3997576f7c458ac9f187","path":"tests/health_test.lua","size":1838}],"format_version":1,"manifest":{"dependencies":{"demi.gameplay.core":"^1.0.0"},"engine":"^0.1.0","exported_events":["damage_applied","health_changed","entity_defeated"],"files":["README.md","scripts/demi/gameplay/health.lua","tests/health_test.lua"],"format_version":1,"name":"demi.gameplay.health","public_modules":["demi.gameplay.health"],"version":"1.0.0"},"package_type":"DemiProjectPackage"}# demi.gameplay.health Stable-ID health state with injected event bus and optional damage/team policy. Scoring, loot, animation, and entity destruction remain game policy. local Health = {} Health.__index = Health function Health.new(events, options) return setmetatable({ events = assert(events), entries = {}, policy = options and options.policy }, Health) end function Health.team_policy(options) options = options or {} return function(request, target, amount, service) local source = request.source and service:get(request.source) or nil if request.source == request.target and options.self_damage == false then return 0 end if source and source.team and target.team and source.team == target.team and options.friendly_fire ~= true then return 0 end return amount end end function Health:add(id, maximum, options) assert(type(id) == "string" and maximum > 0) options = options or {} self.entries[id] = { current = math.min(options.current or maximum, maximum), maximum = maximum, invulnerable = options.invulnerable == true, team = options.team } return self.entries[id] end function Health:get(id) return self.entries[id] end function Health:remove(id) self.entries[id] = nil end function Health:set_invulnerable(id, value) local entry = self.entries[id] if entry then entry.invulnerable = value == true end end function Health:damage(request) local target = self.entries[request.target] if not target then return false, "missing_target" end local amount = math.max(0, tonumber(request.amount) or 0) if amount == 0 then return false, "zero_damage" end if target.invulnerable then return false, "invulnerable" end if self.policy then amount = self.policy(request, target, amount, self) end amount = math.max(0, tonumber(amount) or 0) if amount == 0 then return false, "blocked" end local previous = target.current target.current = math.max(0, previous - amount) self.events:emit("damage_applied", { source = request.source, target = request.target, amount = previous - target.current, type = request.type, point = request.point, tags = request.tags }) self.events:emit("health_changed", { entity = request.target, previous = previous, current = target.current, maximum = target.maximum }) if previous > 0 and target.current == 0 then self.events:emit("entity_defeated", { entity = request.target, source = request.source }) end return true, previous - target.current end function Health:heal(id, amount) local target = self.entries[id] if not target then return false end local previous = target.current target.current = math.min(target.maximum, previous + math.max(0, tonumber(amount) or 0)) if target.current ~= previous then self.events:emit("health_changed", { entity = id, previous = previous, current = target.current, maximum = target.maximum }) end return true, target.current - previous end return Health local Events = require("demi.gameplay.events") local Health = require("demi.gameplay.health") Test.case("lethal hits clamp and defeat once", function() local events, defeated = Events.new(), 0 events:on("entity_defeated", function() defeated = defeated + 1 end) local health = Health.new(events); health:add("enemy", 10) health:damage({ target = "enemy", amount = 50 }); health:damage({ target = "enemy", amount = 1 }) events:flush() Test.equal(health:get("enemy").current, 0); Test.equal(defeated, 1) end) Test.case("invulnerability and healing clamp", function() local events, health = Events.new(), nil health = Health.new(events); health:add("player", 10, { current = 4, invulnerable = true }) local applied = health:damage({ target = "player", amount = 2 }) Test.equal(applied, false) health:heal("player", 100); Test.equal(health:get("player").current, 10) end) Test.case("target removal during event dispatch is safe", function() local events, health = Events.new(), nil health = Health.new(events); health:add("enemy", 1) events:on("damage_applied", function(payload) health:remove(payload.target) end, 10) health:damage({ target = "enemy", amount = 1 }); events:flush() Test.equal(health:get("enemy"), nil) end) Test.case("team policy blocks friendly fire but permits enemies", function() local events = Events.new() local health = Health.new(events, { policy = Health.team_policy() }) health:add("ally", 10, { team = "blue" }) health:add("friend", 10, { team = "blue" }) health:add("enemy", 10, { team = "red" }) Test.equal(health:damage({ source = "ally", target = "friend", amount = 2 }), false) Test.equal(health:damage({ source = "ally", target = "enemy", amount = 2 }), true) Test.equal(health:get("friend").current, 10) Test.equal(health:get("enemy").current, 8) end)