PURPOSE

Runtime for boss-tied add-spawn profiles. Advances the encounter clock and fires waves of pressure-add enemies inside the active boss arena based on time, recurring, or phase triggers declared in the profile data. Pressure adds are normal enemies — they drop XP, emit enemy_kill, and count for streak, but do not contribute to the boss bar (no sharesHealthWithBoss).

OWNS

  • Module-level fire-bookkeeping maps keyed by profileId#waveIndex:
    • recurringFireCount: Map<string, number> — number of recurring fires already emitted per wave.
    • phaseFired: Map<string, Set<number>> — set of phase indices already fired per wave.
    • timeFired: Set<string> — one-shot time waves that have already fired.
  • waveId(profileId, idx) — stable per-wave identity inside the registry.
  • Wave dispatch (fireWave) and per-spawn affix/ability application (applyAffixesAndAbilities).
  • SpawnPosition resolution: cardinal, ring, random, edge_random, opposite_player.

READS FROM

  • GameState.bossSpawnProfile — active profile id (or null / 'none' to skip).
  • GameState.bossArena — required for any wave to fire; positions route through arena helpers (cardinalPoints, ringPoints, randomPoint, edgePoints, oppositePlayer, radius).
  • GameState.bossEncounterTime — accumulated encounter clock; compared against time / recurring trigger windows.
  • WorldState.enemies — scanned for sharesHealthWithBoss && phaseIndex === index to detect phase triggers.
  • ship singleton from ../core/state — used as spawn-origin reference for opposite_player and passed to GameMaster.spawnEnemy.
  • SPAWN_PROFILES registry from ../../data/spawn-profiles — profile and wave definitions (SpawnProfileDef, SpawnWaveDef, SpawnPosition).

PUSHES TO

  • GameState.bossEncounterTime — incremented by dt each tick while a profile is active.
  • WorldState.enemies — via GameMaster.spawnEnemy, which inserts the pressure-add enemy.
  • Spawned EnemyEntity instances — applyAffixesAndAbilities writes enemy.affixes from wave.affixIds and enemy.abilities from wave.abilityIds (each ability starts at cooldownRemaining: 0 with empty state).
  • The module fire-bookkeeping maps — updated as waves fire.

DOES NOT

  • Does not modify the boss entity, the boss HP bar, or anything tagged sharesHealthWithBoss. Pressure adds are standalone normal enemies.
  • Does not despawn or clean up previously-spawned adds. Their lifecycle is the normal enemy lifecycle.
  • Does not select or load the profile — game.bossSpawnProfile is set elsewhere (encounter setup) before this tick runs.
  • Does not stamp phaseIndex on enemies — that is the respawn_as affix’s job when an HP threshold is crossed.
  • Does not silently no-op on an unknown profile id — it throws unknown profile '<id>'.
  • Does not catch up on missed recurring fires in a single tick: when many fires come due simultaneously after a long pause, it steps exactly one fire per tick to spread the spawn cost across frames.
  • Does not validate that spawn positions are reachable beyond what arena helpers guarantee; all positions are arena-relative so adds cannot land outside the encounter footprint.

Signals

  • Inbound: increment of bossSpawnProfile activation (encounter start) and reset via resetBossSpawnProfile() at encounter start, encounter end, and player death — so a freshly started encounter never inherits stale fire counts.
  • Inbound: presence of a sharesHealthWithBoss enemy whose phaseIndex matches a phase-trigger index (set by respawn_as affix on HP threshold crossing).
  • Outbound: each fireWave call routes through GameMaster.spawnEnemy, which is the canonical path that emits the normal-spawn signals downstream (XP drop, enemy_kill on death, streak counting).

Entry points

  • tickBossSpawnProfile(dt, game, world) — called every frame from the engine tick while a boss encounter is active; early-returns when bossSpawnProfile is null / 'none' or when bossArena is null.
  • resetBossSpawnProfile() — called on encounter start, encounter end, and player death to clear all three bookkeeping containers.

Pattern notes

  • Wave identity is ${profileId}#${idx}, so the same wave index in two different profiles is distinct and bookkeeping never collides across the registry.
  • time trigger: one-shot. Fires exactly once when bossEncounterTime >= at, recorded in timeFired.
  • recurring trigger: respects an optional from window (default 0) and an optional until upper bound. The number of fires due so far is floor((now - from) / every) + 1; only one fire is dispatched per tick even when several are due, so a long frame pause spreads the resulting spawn burst across subsequent frames rather than dumping them all at once.
  • phase trigger: fires once per (waveId, index) pair when any live sharesHealthWithBoss enemy reports the matching phaseIndex. phaseFired stores a set per wave so the structure is uniform with multi-phase setups even though a single phase wave will only ever record one entry.
  • anySharingEnemyAtPhase iterates world.enemies with alive and sharesHealthWithBoss guards before checking phaseIndex.
  • Position resolution always routes through BossArena helpers — pressure adds cannot spawn outside the active encounter footprint.
    • cardinal: pads at 0.7 of arena radius via cardinalPoints(0.7); wraps modulo when count > 4.
    • ring: arena.ringPoints(count, 0.7).
    • random: count independent arena.randomPoint() calls.
    • edge_random: arena.edgePoints(count) rotated by a random offset so successive waves don’t keep landing on the same perimeter slots.
    • opposite_player: mirrors the player across arena center at arena.radius() * 0.85, then clusters the spawned count around that base. A single-count spawn lands exactly on the mirror point (jitterDist === 0); multi-count spawns add a random angle and 30 + random*30 distance jitter.
  • SpawnPosition is exhausted with a never-typed fallthrough — adding a new position kind without handling it is a compile-time error backed by a runtime throw.
  • Unknown profile ids throw rather than no-op, matching the project’s crash-loud-on-bad-data stance for internal config.
  • wave.affixIds and wave.abilityIds are optional; when present they fully replace enemy.affixes / enemy.abilities with fresh records (affixes get empty state; abilities get cooldownRemaining: 0 and empty state).