DEMI-PROJECT-PACKAGE Ú{"files":[{"hash":"sha256:ea84cd0740b09ff882d5e4a67c9814603f3f43a630bfa2edf8a70220fa80af30","path":"LICENSE","size":1492},{"hash":"sha256:80f36aaa59f1f1a7f7fe4bfde45680434bc901c968f62651d9660374885914e4","path":"README.md","size":4520},{"hash":"sha256:5e84a4f38d32ab0eaca7ce98335b44f3ff06049cf7c11dcfbd10959e2bbde5a4","path":"scripts/demi/gameplay/destruction/native.lua","size":2825},{"hash":"sha256:77c815931aafa7e3d25dc1297431206940ce9a8a1c290e136c72d70f50a72781","path":"scripts/demi/gameplay/destruction/streaming.lua","size":9228},{"hash":"sha256:f3b5e9f12fe43db2727579e5f83e705e01f9059f56323f5b95b6949df8b070fb","path":"scripts/demi/gameplay/destruction/weapons.lua","size":7686},{"hash":"sha256:b6fe6e6b97949139b6b9f8fda22d698d4764bb2882ee7837b3cdac67e14c8983","path":"tests/streaming_test.lua","size":3923},{"hash":"sha256:8c13ca5850ef16dd7fddf238a697cbce3f543d0ab3b4d60b74883f3db33edaac","path":"tests/weapons_test.lua","size":4104}],"format_version":1,"manifest":{"dependencies":{},"engine":"^0.1.0","exported_events":[],"files":["README.md","LICENSE","scripts/demi/gameplay/destruction/weapons.lua","scripts/demi/gameplay/destruction/native.lua","scripts/demi/gameplay/destruction/streaming.lua","tests/weapons_test.lua","tests/streaming_test.lua"],"format_version":1,"name":"demi.gameplay.destruction","public_modules":["demi.gameplay.destruction.weapons","demi.gameplay.destruction.native","demi.gameplay.destruction.streaming"],"version":"1.0.0"},"package_type":"DemiProjectPackage"}BSD 3-Clause License Copyright (c) 2026, JEAPI Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.# Destruction weapon probes Requires the current DemiEngine development build with the experimental native destruction APIs. Install from a project directory with `demi package add demi.gameplay.destruction@1.0.0`. See `LICENSE` for BSD-3-Clause terms. This is gameplay code; no third-party models or textures are included. `demi.gameplay.destruction.weapons` owns hammer phases, ammunition, cooldowns, bounded rocket flight and cleanup. `demi.gameplay.destruction.native` adapts public engine queries, prefab visuals and native spatial impacts. No shard names or physics-body splitting rules appear in the package. ```lua local Weapons = require("demi.gameplay.destruction.weapons") local Native = require("demi.gameplay.destruction.native") local weapon = Weapons.new(Native.new({owner = "player", rocket_prefab = "prefab://rocket"}), {id_prefix = "player/rocket/"}) ``` Call `hammer()` to begin an attack, `fire(origin, direction)` for a rocket, and `update(fixed_dt, origin, direction)` every fixed step. The hammer queries at the end of its swing, not at button press. Rockets sweep a sphere over their entire step, expire without exploding, and detonate once on first contact. Initial overlaps detonate without spawning a visual. `dispose()` releases active visuals. `reload()` is an immediate diagnostic refill, only allowed with no active rockets. The visual prefab must have an unparented Transform3D root named `body` (override with `rocket_root`) and no collider/rigidbody: propelled rockets use native swept queries, not independent ballistic bodies. A unique `id_prefix` is required for each weapon owner. Supply all services explicitly for isolated unit testing; native modules are imported only by the native adapter. The visual root follows the swept-sphere centre and faces local +Z. Keep the model's leading tip within `rocket_radius`; the example offsets its shell so the visible nose and collision front agree. `hammer_phase()` returns phase/progress for visual animation. `drain_events()` returns bounded presentation events (the latest 32), not authoritative damage. Counters `shots`, `detonations`, `contacts`, `ammo`, and `rockets` can be polled. An accepted impact is queued; native destruction may still reject its commit. The native adapter attaches an optional `receipt` to impact events, containing the directly hit target's stable assembly `root`, pre-queue `revision`, and original `target` body ID. Poll `Destruction.state(receipt.root)` to distinguish queued, failed and revision-advanced batches; the original body may be retired by a split. This tracks the primary target's assembly, not all assemblies touched by a radial explosion, and is diagnostic rather than a per-request transaction ID. Custom impact services can return this metadata as their fourth result; the optional second service argument is the directly hit body (not a radial filter). This is a local gameplay foundation, not network authority, an inventory system, ballistic rocket physics or a full animation system. Initial overlap checks and native sweeps ignore trigger volumes while retaining blocking geometry behind them. ## Streamed structures `demi.gameplay.destruction.streaming` accepts compact `{id,prefab,position}` records and `Native.streaming()` services. Records allocate no engine entities until they enter range. `update(dt, observer_position)` uses a spatial index, bounded scan/spawn budgets and an active-instance cap. Defaults: 24 m load, 32 m unload, 8 active, 1 spawn/update, 256 candidates/update. Distance uses assembly origins, not individual debris bounds. First-use generation is synchronous. Damage is retained in memory on unload (`preserve=false` explicitly resets a record instead). `debris_lifetime=0` disables cleanup; a positive value opts into retiring detached groups after idle active time, while preserving the resulting holes. `export_state()` returns a versioned, geometry-free save table; pass it to `demi.save` only when disk persistence is wanted. `import_state(state)` must run before activation. Template mismatch fails rather than silently restoring a pristine wall. Check `last_error`; `active_count` and `last_scanned` are diagnostics. `dispose()` releases this manager's instances; export first if their state must survive disposal. This package is policy over native checkpoint/restore/cleanup, not an alternate Lua physics or fracture implementation. See the engine's `docs/streamed-destruction.md` for authoring, cache limits and qualification gaps. local Native = {} function Native.streaming() local Prefab=require("demi.prefab") local Destruction=require("demi.physics.destruction3d") local Entity=require("demi.entity") local function root(entry) return entry.id.."/"..(entry.root or "assembly") end return { spawn=function(entry) return Prefab.instantiate(entry.prefab,{id=entry.id,position=entry.position})~=nil end, release=function(entry) return Prefab.release(entry.id) or not Entity.exists(root(entry)) end, state=function(entry) return Destruction.state(root(entry)) end, checkpoint=function(entry) return Destruction.checkpoint(root(entry)) end, restore=function(entry,saved) return Destruction.restore(root(entry),saved) end, retire=function(entry) return Destruction.retire_debris(root(entry)) end, } end function Native.new(options) assert(options and type(options.rocket_prefab)=="string" and options.rocket_prefab:match("^prefab://"), "provide a rocket_prefab reference") local Physics = require("demi.physics.query3d") local Destruction = require("demi.physics.destruction3d") local Prefab = require("demi.prefab") local Entity = require("demi.entity") local Transform = require("demi.transform3d") local Vector = require("demi.math.vector3") local root = options.rocket_root or "body" local function move(id, position, direction) local body = id .. "/" .. root if not Entity.exists(body) then return end -- deferred prefab insertion Transform.set_position(body, position[1], position[2], position[3]) local target = Vector.add(position, direction) Transform.look_at(body, target[1], target[2], target[3]) end return { vector = Vector, cast = function(origin, direction, distance, radius) local overlaps = Physics.overlap_sphere_all(origin[1], origin[2], origin[3], radius, nil, options.owner) for _, hit in ipairs(overlaps) do if not hit.is_trigger then return hit end end if distance == 0 then return nil end return Physics.sphere_cast(origin[1], origin[2], origin[3], radius, direction[1], direction[2], direction[3], distance, nil, options.owner, false) end, impact = function(hit, target) -- Keep the stable assembly identity before a split retires the hit body. local before = target and Destruction.state(target) local ok, issue, affected = Destruction.impact(hit) local receipt = before and before.root~="" and { root=before.root, revision=before.revision, target=target, } or nil return ok, issue, affected, receipt end, spawn = function(id, position, direction) return Prefab.instantiate(options.rocket_prefab, {id = id, position = position}) ~= nil end, move = move, release = function(id) return Prefab.release(id) end, } end return Native -- Proximity/lifetime policy. Geometry, damage and restoration remain native. local Streaming = {} Streaming.__index = Streaming local function finite(n) return type(n)=="number" and n==n and math.abs(n)=0,"invalid streaming setting: "..key) settings[key]=value end assert(settings.load_distance>0 and settings.unload_distance>settings.load_distance,"unload distance must exceed load distance") assert(settings.activation_timeout>0,"activation timeout must be positive") for _,key in ipairs({"max_active","spawn_budget","scan_budget"}) do assert(settings[key]>=1 and settings[key]%1==0 and settings[key]<=100000,"invalid streaming budget") end assert(type(definitions)=="table" and #definitions<=1000000,"invalid wall catalogue") local self=setmetatable({services=services,settings=settings,entries={},cells={},active={}, saved={},retry={},clock=0,last_error="",last_scanned=0,active_count=0},Streaming) for _,entry in ipairs(definitions) do assert(type(entry.id)=="string" and entry.id~="" and not self.entries[entry.id],"duplicate/invalid wall ID") assert(type(entry.prefab)=="string" and entry.prefab:match("^prefab://"),"invalid prefab") position(entry.position) entry=copy(entry); self.entries[entry.id]=entry local key=math.floor(entry.position[1]/settings.load_distance)..":"..math.floor(entry.position[3]/settings.load_distance) local cell=self.cells[key] or {cursor=1,entries={}}; self.cells[key]=cell cell.entries[#cell.entries+1]=entry end return self end function Streaming:capture(entry) if entry.preserve==false then return nil end local state=self.services.state(entry) if state.status=="queued" or state.status=="unattached" then return nil,"Wall is not ready for checkpoint: "..entry.id end if state.status=="failed" then return nil,state.error end if state.revision==0 then return nil end local value,error=self.services.checkpoint(entry) if not value then return nil,error end return {prefab=entry.prefab,position=copy(entry.position),checkpoint=value} end function Streaming:update(dt,observer) assert(finite(dt) and dt>=0,"invalid streaming dt"); position(observer) self.clock=self.clock+dt; self.last_scanned=0; self.last_error="" local retire={} for _,id in ipairs(keys(self.active)) do local active=self.active[id] local entry=active.entry; local state=self.services.state(entry) if (active.phase=="loading" or active.phase=="restoring") and self.clock-active.born>self.settings.activation_timeout then self.last_error="Wall activation timed out: "..id;active.phase="failed" elseif state.status=="failed" then self.last_error=state.error or ("Wall failed: "..id); active.phase="failed" elseif active.phase=="loading" and state.status~="unattached" then local saved=self.saved[id] if saved then local ok,error=self.services.restore(entry,copy(saved.checkpoint)) if ok then active.phase="restoring" else self.last_error=error; active.phase="failed" end else active.phase="ready" end elseif active.phase=="restoring" and state.status~="queued" then if state.status=="applied" then active.phase="ready" else self.last_error=state.error; active.phase="failed" end end if active.phase=="failed" then if self.services.release(entry) then retire[#retire+1]=id; self.retry[id]=self.clock+2 end elseif active.phase=="ready" then if state.revision~=active.revision then active.revision=state.revision; active.age=0 end active.age=active.age+dt if active.cleaning and state.status=="applied" then active.cleaned=state.revision; active.cleaning=false end local lifetime=self.settings.debris_lifetime if lifetime>0 and state.revision>0 and active.age>=lifetime and active.cleaned~=state.revision and state.status=="applied" then local ok,error=self.services.retire(entry) if ok then active.cleaning=true else self.last_error=error end elseif distance(entry.position,observer)>self.settings.unload_distance^2 then local snapshot,error=self:capture(entry) if error then self.last_error=error elseif self.services.release(entry) then self.saved[id]=snapshot; retire[#retire+1]=id else self.last_error="Could not release wall: "..id end end end end for _,id in ipairs(retire) do self.active[id]=nil; self.active_count=self.active_count-1 end local candidates={} local cx=math.floor(observer[1]/self.settings.load_distance) local cz=math.floor(observer[3]/self.settings.load_distance) local allowance=math.max(1,math.floor(self.settings.scan_budget/9)) for x=cx-1,cx+1 do for z=cz-1,cz+1 do local cell=self.cells[x..":"..z] if cell then for _=1,math.min(allowance,#cell.entries,self.settings.scan_budget-self.last_scanned) do local entry=cell.entries[cell.cursor]; cell.cursor=cell.cursor%#cell.entries+1 self.last_scanned=self.last_scanned+1 local d=distance(entry.position,observer) if not self.active[entry.id] and (self.retry[entry.id] or 0)<=self.clock and d<=self.settings.load_distance^2 then candidates[#candidates+1]={entry=entry,distance=d} end end end end end table.sort(candidates,function(a,b) return a.distance==b.distance and a.entry.id=self.settings.spawn_budget then break end local entry=candidates[i].entry if self.active_count>=self.settings.max_active then local worst,worst_distance=nil,math.sqrt(candidates[i].distance)+2 for id,active in pairs(self.active) do local d=math.sqrt(distance(active.entry.position,observer)) if active.phase=="ready" and (d>worst_distance or (d==worst_distance and (not worst or id>worst))) then worst=id;worst_distance=d end end if worst then local snapshot,error=self:capture(self.active[worst].entry) if error then self.last_error=error elseif self.services.release(self.active[worst].entry) then self.saved[worst]=snapshot;self.active[worst]=nil;self.active_count=self.active_count-1 end end end if self.active_count= 0, "invalid weapon setting: " .. name) settings[name] = value end assert(settings.windup > 0 and settings.swing > 0 and settings.recovery > 0, "attack phases must be positive") assert(finite(settings.windup + settings.swing + settings.recovery), "attack duration overflow") assert(settings.rocket_speed > 0 and settings.rocket_life > 0 and settings.hammer_reach > 0, "invalid weapon range/lifetime") assert(settings.rocket_speed <= 1000 and settings.rocket_life <= 60 and settings.hammer_reach <= 1000, "weapon range/speed/lifetime exceeds supported limits") for _,name in ipairs({"hammer_radius","rocket_radius","blast_radius"}) do assert(settings[name] >= 0.001 and settings[name] <= 1000, "invalid radius: "..name) end assert(settings.hammer_energy <= 1e12 and settings.blast_energy <= 1e12 and settings.hammer_impulse <= 1e9 and settings.blast_impulse <= 1e9, "impact budget exceeds native limits") assert(settings.ammo % 1 == 0 and settings.ammo <= 1000000 and settings.max_rockets % 1 == 0 and settings.max_rockets >= 1 and settings.max_rockets <= 64, "invalid ammunition/projectile limit") return setmetatable({ services = services, settings = settings, vector = services.vector or require("demi.math.vector3"), prefix = assert(options and options.id_prefix, "provide a unique weapon id_prefix"), ammo = settings.ammo, cooldown = 0, clock = nil, contacted = false, rockets = {}, serial = 0, events = {}, shots = 0, detonations = 0, contacts = 0 }, Weapons) end function Weapons:emit(kind, values) values = values or {}; values.kind = kind if #self.events == 32 then table.remove(self.events, 1) end self.events[#self.events + 1] = values end function Weapons:drain_events() local events = self.events; self.events = {}; return events end function Weapons:aim(origin, direction) vector(origin); vector(direction) assert(self.vector.length(direction) > 0.000001, "aim direction must be nonzero") return {origin[1], origin[2], origin[3]}, self.vector.normalized(direction) end function Weapons:hammer() if self.clock then return false, "Hammer recovering" end self.clock, self.contacted = 0, false self:emit("hammer_started") return true end function Weapons:hammer_phase() if not self.clock then return "idle", 0 end local s, t = self.settings, self.clock if t < s.windup then return "windup", t / s.windup end if t < s.windup + s.swing then return "swing", (t - s.windup) / s.swing end return "recovery", math.min(1, (t - s.windup - s.swing) / s.recovery) end function Weapons:detonate(hit) local v, s = self.vector, self.settings self.detonations = self.detonations + 1 local position = v.add(hit.point, v.scale(hit.normal or {0,0,0}, 0.1)) local ok, issue, affected, receipt = self.services.impact({ position = position, radius = s.blast_radius, energy = s.blast_energy, impulse = s.blast_impulse }, hit.entity_id) self:emit("explosion", {position = position, accepted = ok, error = issue, affected = affected or 0, receipt = receipt}) end function Weapons:fire(origin, direction) if self.cooldown > 0 then return false, "Rocket cooling down" end if self.ammo == 0 then return false, "Out of rockets" end if #self.rockets >= self.settings.max_rockets then return false, "Projectile limit reached" end origin, direction = self:aim(origin, direction) local inside = self.services.cast(origin, direction, 0, self.settings.rocket_radius) local id = self.prefix .. tostring(self.serial + 1) if not inside and not self.services.spawn(id, origin, direction) then return false, "Rocket visual spawn failed" end self.serial, self.shots = self.serial + 1, self.shots + 1 self.ammo, self.cooldown = self.ammo - 1, self.settings.rocket_cooldown self:emit("rocket_fired", {id = id}) if inside then self:detonate(inside) else self.rockets[#self.rockets + 1] = {id = id, position = origin, direction = direction, life = self.settings.rocket_life} end return true end function Weapons:update(dt, origin, direction) assert(finite(dt) and dt >= 0, "weapon dt must be finite and non-negative") origin, direction = self:aim(origin, direction) self.cooldown = math.max(0, self.cooldown - dt) if self.clock then self.clock = self.clock + dt local s = self.settings if not self.contacted and self.clock >= s.windup + s.swing then self.contacted = true local hit = self.services.cast(origin, direction, s.hammer_reach, s.hammer_radius) if hit then self.contacts = self.contacts + 1 local ok, issue, affected, receipt = self.services.impact({ entity = hit.entity_id, position = hit.point, direction = direction, radius = s.hammer_radius, energy = s.hammer_energy, impulse = s.hammer_impulse }, hit.entity_id) self:emit("hammer_contact", {accepted = ok, error = issue, affected = affected or 0, receipt = receipt}) else self:emit("hammer_miss") end end if self.clock >= s.windup + s.swing + s.recovery then self.clock = nil end end for index = #self.rockets, 1, -1 do local rocket, s = self.rockets[index], self.settings local elapsed = math.min(dt, rocket.life) local distance = elapsed * s.rocket_speed local hit = self.services.cast(rocket.position, rocket.direction, distance, s.rocket_radius) rocket.life = rocket.life - elapsed if hit or rocket.life <= 0 then if hit then self:detonate(hit) else self:emit("rocket_expired", {id = rocket.id}) end self.services.release(rocket.id) table.remove(self.rockets, index) else rocket.position = self.vector.add(rocket.position, self.vector.scale(rocket.direction, distance)) self.services.move(rocket.id, rocket.position, rocket.direction) end end end function Weapons:reload() if #self.rockets > 0 then return false, "Wait for active rockets" end self.ammo = self.settings.ammo return true end function Weapons:dispose() for _, rocket in ipairs(self.rockets) do self.services.release(rocket.id) end self.rockets, self.clock = {}, nil end return Weapons local Test=require("demi.test") local Streaming=require("demi.gameplay.destruction.streaming") local function fixture(definitions,options) local calls={spawned=0,released=0,restored=0,retired=0,states={}} local services={ spawn=function(e) calls.spawned=calls.spawned+1;calls.states[e.id]={status="ready",revision=0};return true end, release=function(e) calls.released=calls.released+1;calls.states[e.id]=nil;return true end, state=function(e) return calls.states[e.id] or {status="unattached",revision=0} end, checkpoint=function(e) return {damage=calls.states[e.id].revision} end, restore=function(e,s) calls.restored=calls.restored+1;calls.states[e.id]={status="applied",revision=s.damage};return true end, retire=function(e) calls.retired=calls.retired+1;local s=calls.states[e.id];s.revision=s.revision+1;return true end, } return Streaming.new(services,definitions,options),calls end Test.case("100000 wall records do not instantiate 100000 prefabs",function() local definitions={} for i=1,100000 do definitions[i]={id="wall"..i,prefab="prefab://wall",position={i*5,0,0}} end local stream,calls=fixture(definitions,{max_active=4,spawn_budget=1,scan_budget=36}) Test.equal(calls.spawned,0) for i=1,20 do stream:update(.1,{0,0,0});Test.truthy(stream.last_scanned<=36);Test.truthy(stream.active_count<=4) end Test.equal(calls.spawned,4) for i=1,20 do stream:update(.1,{50000,0,0});Test.truthy(stream.last_scanned<=36);Test.truthy(stream.active_count<=4) end Test.truthy(calls.released>=4);Test.truthy(calls.spawned<=8) end) Test.case("damage survives distance unloading and optional save roundtrip",function() local definitions={{id="a",prefab="prefab://wall",position={0,0,0}}} local stream,calls=fixture(definitions) stream:update(.1,{0,0,0});stream:update(.1,{0,0,0}) calls.states.a={status="applied",revision=3} stream:update(.1,{100,0,0});Test.equal(stream.active_count,0) local save=assert(stream:export_state());Test.equal(save.walls.a.checkpoint.damage,3) local other,restored=fixture(definitions);Test.truthy(other:import_state(save)) for i=1,4 do other:update(.1,{0,0,0}) end Test.equal(restored.restored,1);Test.equal(restored.states.a.revision,3) save.walls.a.checkpoint.damage=10 Test.equal(restored.states.a.revision,3) end) Test.case("pending damage is not discarded on unload",function() local stream,calls=fixture({{id="a",prefab="prefab://wall",position={0,0,0}}}) stream:update(.1,{0,0,0});stream:update(.1,{0,0,0}) calls.states.a.status="queued" stream:update(.1,{100,0,0});Test.equal(calls.released,0) Test.equal(stream:export_state(),nil) calls.states.a={status="applied",revision=1} stream:update(.1,{100,0,0});Test.equal(calls.released,1) end) Test.case("lifetime cleanup is opt-in and does not repeat without new damage",function() local stream,calls=fixture({{id="a",prefab="prefab://wall",position={0,0,0}}},{debris_lifetime=1}) stream:update(.1,{0,0,0});stream:update(.1,{0,0,0});calls.states.a={status="applied",revision=1} for i=1,30 do stream:update(.1,{0,0,0}) end Test.equal(calls.retired,1) end) Test.case("closer walls replace farther ready walls when the active budget is full",function() local stream,calls=fixture({ {id="a",prefab="prefab://wall",position={0,0,0}}, {id="b",prefab="prefab://wall",position={15,0,0}}, },{max_active=1}) stream:update(.1,{0,0,0});stream:update(.1,{0,0,0}) stream:update(.1,{10,0,0}) Test.equal(calls.released,1);Test.truthy(stream.active.b);Test.equal(stream.active_count,1) end) Test.case("an unattached activation times out instead of leaking a capacity slot",function() local stream,calls=fixture({{id="a",prefab="prefab://wall",position={0,0,0}}},{activation_timeout=.5}) stream:update(.1,{0,0,0});calls.states.a=nil for i=1,10 do stream:update(.1,{0,0,0}) end Test.equal(calls.released,1);Test.equal(stream.active_count,0) end) local Test = require("demi.test") local Weapons = require("demi.gameplay.destruction.weapons") local V = {} function V.add(a,b) return {a[1]+b[1],a[2]+b[2],a[3]+b[3]} end function V.scale(a,s) return {a[1]*s,a[2]*s,a[3]*s} end function V.length(a) return math.sqrt(a[1]^2+a[2]^2+a[3]^2) end function V.normalized(a) return V.scale(a,1/V.length(a)) end local function fixture(options) local calls = {hits={}, casts={}, spawned=0, released=0, moved=0} local services = { vector=V, cast=function(o,d,length,radius) calls.casts[#calls.casts+1] = length if calls.miss then return nil end if o[3] <= -2 or (d[3] < 0 and (o[3]+2)/-d[3] <= length) then return {entity_id="wall", point={0,0,-2}, normal={0,0,1}} end end, impact=function(hit,target) calls.hits[#calls.hits+1]=hit calls.target=target return true,"",1,{root="assembly",revision=2,target=target} end, spawn=function() calls.spawned=calls.spawned+1; return not calls.fail_spawn end, move=function() calls.moved=calls.moved+1 end, release=function() calls.released=calls.released+1 end, } options = options or {}; options.id_prefix="fixture/" return Weapons.new(services, options), calls end Test.case("hammer only damages once at contact, within reach", function() local w,c=fixture() Test.truthy(w:hammer()); Test.equal(w:hammer(),false) w:update(0.15,{0,0,0},{0,0,-1}); Test.equal(#c.hits,0) w:update(0.12,{0,0,0},{0,0,-1}); Test.equal(#c.hits,0) w:update(0.02,{0,0,0},{0,0,-1}); Test.equal(#c.hits,1) w:update(2,{0,0,0},{0,0,-1}); Test.equal(#c.hits,1) Test.equal(c.hits[1].entity,"wall") Test.equal(c.target,"wall") local events=w:drain_events() Test.equal(events[#events].receipt.root,"assembly") Test.equal(events[#events].receipt.revision,2) local short, missed=fixture({hammer_reach=1}) short:hammer(); short:update(2,{0,0,0},{0,0,-1}); Test.equal(#missed.hits,0) end) Test.case("rocket travels then sweeps through thin geometry exactly once", function() local w,c=fixture() Test.truthy(w:fire({0,0,0},{0,0,-2})) Test.equal(#c.hits,0); Test.equal(w.ammo,7) Test.equal(w:fire({0,0,0},{0,0,-1}),false) w:update(0.1,{0,0,0},{0,0,-1}); Test.equal(#c.hits,0); Test.equal(c.moved,1) w:update(1,{0,0,0},{0,0,-1}); Test.equal(#c.hits,1); Test.equal(c.released,1) Test.equal(c.hits[1].entity,nil); Test.equal(c.hits[1].direction,nil) Test.equal(c.target,"wall") -- diagnostic target must not filter radial damage w:update(1,{0,0,0},{0,0,-1}); Test.equal(#c.hits,1) end) Test.case("expiry clips travel to remaining lifetime and does not explode", function() local w,c=fixture({rocket_life=0.2}); c.miss=true w:fire({0,0,0},{0,0,-1}); w:update(10,{0,0,0},{0,0,-1}) Test.near(c.casts[#c.casts],1.6); Test.equal(#c.hits,0); Test.equal(c.released,1) end) Test.case("spawn failures, limits, ammo and disposal do not leak state", function() local w,c=fixture({ammo=1,max_rockets=1,rocket_cooldown=0}); c.miss=true; c.fail_spawn=true Test.equal(w:fire({0,0,0},{0,0,-1}),false); Test.equal(w.ammo,1) c.fail_spawn=false; Test.truthy(w:fire({0,0,0},{0,0,-1})) Test.equal(w:fire({0,0,0},{0,0,-1}),false); Test.equal(w:reload(),false) w:dispose(); Test.equal(c.released,1); Test.equal(#w.rockets,0) Test.truthy(w:reload()); Test.equal(w.ammo,1) end) Test.case("initial overlaps detonate without creating an orphan visual", function() local w,c=fixture() Test.truthy(w:fire({0,0,-3},{0,0,-1})) Test.equal(#c.hits,1); Test.equal(c.spawned,0); Test.equal(#w.rockets,0) end) Test.case("invalid configuration, aim and time fail before emitting effects", function() Test.equal(pcall(function() fixture({rocket_speed=0}) end),false) Test.equal(pcall(function() fixture({rocket_speeed=12}) end),false) local w,c=fixture() Test.equal(pcall(function() w:fire(nil,{0,0,-1}) end),false) Test.equal(pcall(function() w:fire({0,0,0},{0,0,0}) end),false) Test.equal(pcall(function() w:update(-1,{0,0,0},{0,0,-1}) end),false) Test.equal(#c.casts,0); Test.equal(c.spawned,0); Test.equal(w.ammo,8) end)