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-shottimewaves that have already fired.
waveId(profileId, idx)— stable per-wave identity inside the registry.- Wave dispatch (
fireWave) and per-spawn affix/ability application (applyAffixesAndAbilities). SpawnPositionresolution:cardinal,ring,random,edge_random,opposite_player.
READS FROM
GameState.bossSpawnProfile— active profile id (ornull/'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 againsttime/recurringtrigger windows.WorldState.enemies— scanned forsharesHealthWithBoss && phaseIndex === indexto detectphasetriggers.shipsingleton from../core/state— used as spawn-origin reference foropposite_playerand passed toGameMaster.spawnEnemy.SPAWN_PROFILESregistry from../../data/spawn-profiles— profile and wave definitions (SpawnProfileDef,SpawnWaveDef,SpawnPosition).
PUSHES TO
GameState.bossEncounterTime— incremented bydteach tick while a profile is active.WorldState.enemies— viaGameMaster.spawnEnemy, which inserts the pressure-add enemy.- Spawned
EnemyEntityinstances —applyAffixesAndAbilitieswritesenemy.affixesfromwave.affixIdsandenemy.abilitiesfromwave.abilityIds(each ability starts atcooldownRemaining: 0with emptystate). - 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.bossSpawnProfileis set elsewhere (encounter setup) before this tick runs. - Does not stamp
phaseIndexon enemies — that is therespawn_asaffix’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
bossSpawnProfileactivation (encounter start) and reset viaresetBossSpawnProfile()at encounter start, encounter end, and player death — so a freshly started encounter never inherits stale fire counts. - Inbound: presence of a
sharesHealthWithBossenemy whosephaseIndexmatches aphase-trigger index (set byrespawn_asaffix on HP threshold crossing). - Outbound: each
fireWavecall routes throughGameMaster.spawnEnemy, which is the canonical path that emits the normal-spawn signals downstream (XP drop,enemy_killon death, streak counting).
Entry points
tickBossSpawnProfile(dt, game, world)— called every frame from the engine tick while a boss encounter is active; early-returns whenbossSpawnProfileisnull/'none'or whenbossArenaisnull.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. timetrigger: one-shot. Fires exactly once whenbossEncounterTime >= at, recorded intimeFired.recurringtrigger: respects an optionalfromwindow (default0) and an optionaluntilupper bound. The number of fires due so far isfloor((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.phasetrigger: fires once per(waveId, index)pair when any livesharesHealthWithBossenemy reports the matchingphaseIndex.phaseFiredstores 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.anySharingEnemyAtPhaseiteratesworld.enemieswithaliveandsharesHealthWithBossguards before checkingphaseIndex.- Position resolution always routes through
BossArenahelpers — pressure adds cannot spawn outside the active encounter footprint.cardinal: pads at0.7of arena radius viacardinalPoints(0.7); wraps modulo whencount > 4.ring:arena.ringPoints(count, 0.7).random:countindependentarena.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 atarena.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 and30 + random*30distance jitter.
SpawnPositionis exhausted with anever-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.affixIdsandwave.abilityIdsare optional; when present they fully replaceenemy.affixes/enemy.abilitieswith fresh records (affixes get emptystate; abilities getcooldownRemaining: 0and emptystate).