DEMI-PROJECT-PACKAGE M{"files":[{"hash":"sha256:319ef825822fae399b3db704b18afdbeaaefa744822d736bed7f6c0c60c02939","path":"README.md","size":2446},{"hash":"sha256:e7c5814df1278b550787625fa63009a3b11430327017d7d37af91d582ae53a4a","path":"scripts/demi/gameplay/encounters.lua","size":1696},{"hash":"sha256:eea652c30786f28f6265a660a4c8554a5543b634696c5367746f1428887c37ea","path":"tests/encounters_test.lua","size":846}],"format_version":1,"manifest":{"dependencies":{"demi.gameplay.events":"^1.0.0"},"engine":"^0.1.0","exported_events":["wave_started","spawn_requested","spawn_failed","objective_failed","encounter_completed"],"files":["README.md","scripts/demi/gameplay/encounters.lua","tests/encounters_test.lua"],"format_version":1,"name":"demi.gameplay.encounters","public_modules":["demi.gameplay.encounters"],"version":"1.1.0"},"package_type":"DemiProjectPackage"}# Encounter waves Package: `demi.gameplay.encounters`. Schedules spawn callbacks for waves, tracks live IDs, and reports completion or an explicit objective failure. Exports `demi.gameplay.encounters`; depends on `demi.gameplay.events`. See [package installation](../../README.md). This minimal example uses an in-memory spawn callback. Replace it with game code that creates an entity and returns its unique ID, or `nil, reason`: ```lua local Events = require("demi.gameplay.events") local Encounters = require("demi.gameplay.encounters") local events = Events.new() local encounter = Encounters.new(events, function(request) return "enemy-" .. request.order end) encounter:configure({ { spawns = { { at = 0, prefab = "prefab://game/raider", entrance = "east" } } }, }) assert(encounter:start_next()) encounter:update(0) events:flush() encounter:defeated("enemy-1") if encounter:ready_for_next() then encounter:start_next() end events:flush() assert(encounter.completed) ``` `update(elapsed)` takes elapsed time since the current wave started, not a frame delta. The game owns that clock and resets it for each wave. Due requests run in reverse definition order, not sorted timestamp order. Each request is removed after one callback attempt; failures are not retried automatically. The caller owns and flushes the bus. `wave_started`, `spawn_requested`, `spawn_failed`, `objective_failed`, and `encounter_completed` are queued. Despite its name, `spawn_requested` is emitted after the spawn callback returns an ID. Keep listener unsubscribe functions and call them on owner teardown. Connect game defeat notifications to `defeated(id)` yourself. `ready_for_next` checks that both pending requests and live IDs are empty. `start_next` does not enforce that check: the game must guard advancement. Calling it beyond the last wave emits completion and returns `false`. `fail(reason)` emits failure once and blocks further `start_next` calls, but does not cancel pending requests or stop `update`; stop updating after failure if that is the desired rule. `configure` only replaces wave definitions, so create a new instance for a fresh encounter. The package does not evaluate objectives, spawn engine entities itself, perform navigation, or assign rewards. Spawn success and timing depend on game code. From the repository root, test with: ```sh ./build/linux-debug/demi package test packages/sources/demi.gameplay.encounters ``` local Encounters = {} Encounters.__index = Encounters function Encounters.new(events, spawn) return setmetatable({ events = assert(events), spawn = assert(spawn), waves = {}, wave = 0, pending = {}, alive = {}, completed = false, failed = false }, Encounters) end function Encounters:configure(waves) self.waves = waves or {} end function Encounters:start_next() if self.failed or self.completed then return false end self.wave = self.wave + 1 local definition = self.waves[self.wave] if not definition then self.completed = true; self.events:emit("encounter_completed", {}); return false end self.pending = {} for index, spawn in ipairs(definition.spawns or {}) do self.pending[index] = { at = spawn.at or 0, prefab = spawn.prefab, entrance = spawn.entrance, order = index } end self.events:emit("wave_started", { wave = self.wave }); return true end function Encounters:update(elapsed) for index = #self.pending, 1, -1 do local request = self.pending[index] if request.at <= elapsed then local id, reason = self.spawn(request) if id then self.alive[id] = true; self.events:emit("spawn_requested", { entity = id, wave = self.wave }) else self.events:emit("spawn_failed", { wave = self.wave, reason = reason, order = request.order }) end table.remove(self.pending, index) end end end function Encounters:defeated(id) self.alive[id] = nil end function Encounters:ready_for_next() return #self.pending == 0 and next(self.alive) == nil end function Encounters:fail(reason) if self.failed then return false end self.failed = true; self.events:emit("objective_failed", { reason = reason }); return true end return Encounters local Test = require("demi.test") local Events = require("demi.gameplay.events") local Encounters = require("demi.gameplay.encounters") Test.case("spawn failures are explicit and stable", function() local events, failed = Events.new(), nil events:on("spawn_failed", function(value) failed = value.reason end) local encounter = Encounters.new(events, function() return nil, "path_unavailable" end) encounter:configure({ { spawns = { { at = 0, prefab = "raider" } } } }) encounter:start_next(); encounter:update(0); events:flush() Test.equal(failed, "path_unavailable"); Test.equal(encounter:ready_for_next(), true) end) Test.case("objective fails once", function() local encounter = Encounters.new(Events.new(), function() end) Test.equal(encounter:fail("keep_destroyed"), true); Test.equal(encounter:fail("again"), false) end)