[High] [Bug] Spells continue casting and draining mana after all enemies are dead in a room #346

Closed
opened 2026-06-10 10:03:22 +02:00 by Anexim · 2 comments
Owner

Bug: Spells continue casting and draining mana after all enemies in a room are dead

Description

When descending the spire and reaching floor 1 room 0, if the player has cleared the enemy in that room, they continue to cast spells and drain mana for no reason. Once there are no enemies, spell casting should stop.

This is not limited to floor 1 room 0 — it affects any combat room where all enemies have been killed but the room has not yet been cleared (e.g., during the same tick where the killing blow lands, or in edge cases during descent where a room with 0 enemies is generated).


Root Cause

Missing floorHP > 0 guard on the primary spell casting loop.

In src/lib/game/stores/combat-actions.ts, the primary spell casting while loop (lines ~148–196) has this condition:

while (weaponCastProgress['primary'] >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK)

It checks:

  1. Cast progress >= 1 (ready to cast)
  2. Can afford mana cost
  3. Safety counter

It does NOT check whether there are any living enemies (floorHP > 0 or currentRoom.enemies.some(e => e.hp > 0)).

Compare with the melee sword loop (line 267) which does have this guard:

if (equippedSwords && Object.keys(equippedSwords).length > 0 && floorHP > 0) {

And the golem attack loop (line 312):

if (activeGolems.length > 0 && floorHP > 0) {

And the DoT processing (line 335):

if (floorHP > 0) {

So the primary spell loop is the only combat damage source that lacks an enemy-presence check.

What Happens Step-by-Step

  1. Player descends to floor 1 room 0 (or any room where enemies are cleared)
  2. currentAction === 'climb' so the combat tick runs
  3. spellDef is truthy (player has manaBolt active)
  4. weaponCastProgress['primary'] accumulates past 1.0 from previous ticks
  5. The while loop fires: cast progress >= 1 AND player has mana → spell casts
  6. deductSpellCost() drains mana
  7. applyDamageToRoom() is called — since room.enemies.length === 0, it falls back to direct floorHP subtraction (line 43–47 of combat-damage.ts), setting floorHP to 0 and returning roomCleared: true
  8. advanceRoomOrFloor() is called, moving to the next room
  9. But if weaponCastProgress['primary'] was >= 2 (multiple casts queued), the loop continues casting into the new room without checking if that room has enemies
  10. Even in a room with 0 enemies (e.g., a non-combat room or an empty room), the loop keeps draining mana

The applyDamageToRoom Empty-Enemies Fallback Makes It Worse

In combat-damage.ts lines 43–47:

if (!room || room.enemies.length === 0) {
  // No enemies in room — fall back to direct floorHP subtraction
  const newFloorHP = Math.max(0, state.floorHP - dmg);
  set({ floorHP: newFloorHP });
  return { floorHP: newFloorHP, floorMaxHP: state.floorMaxHP, roomCleared: newFloorHP <= 0 };
}

When enemies array is empty, damage is still applied to floorHP directly, and roomCleared returns true when floorHP <= 0. This means:

  • Spells keep "hitting" nothing
  • Mana keeps being drained
  • roomCleared triggers advanceRoomOrFloor() repeatedly
  • Each advancement resets weaponCastProgress['primary'] to 0, but the loop may have already queued multiple casts

Is This an Issue in Non-Combat Rooms?

Yes, partially. Non-combat rooms (library, recovery, treasure, puzzle) have enemies: [] by design. However, the non-combat room tick path in gameStore.ts (line 295–307) only runs when roomType is one of those types, and the combat tick path only runs when currentAction === 'climb'. The issue is that:

  1. If a non-combat room is entered but currentAction is still 'climb', the combat tick still runs
  2. The spell loop fires with no enemies, draining mana
  3. applyDamageToRoom with empty enemies applies damage to floorHP (which is 0 for non-combat rooms), triggering roomCleared: true
  4. This can cause premature advancement out of the non-combat room

However, in practice the non-combat room tick handler in gameStore.ts (line 295) runs after the combat tick, so the combat tick may advance the room before the non-combat handler gets a chance to process it.

Severity Assessment

Factor Assessment
Impact High — mana is wasted on empty rooms, potentially leaving the player without mana for actual combat
Scope All combat rooms where enemies are killed; any room with empty enemies array
Frequency Every time a room is cleared, the remaining cast progress fires into the next room or into nothing
Player-facing Visible as mana draining rapidly with no enemies present; confusing and wasteful

Files Involved

File Line(s) Role
src/lib/game/stores/combat-actions.ts ~148–196 Primary spell casting loop — missing floorHP > 0 guard
src/lib/game/stores/combat-actions.ts 267 Melee loop — has floorHP > 0 guard (correct)
src/lib/game/stores/combat-actions.ts 312 Golem loop — has floorHP > 0 guard (correct)
src/lib/game/stores/combat-actions.ts 335 DoT processing — has floorHP > 0 guard (correct)
src/lib/game/stores/combat-damage.ts 43–47 applyDamageToRoom empty-enemies fallback — applies damage to floorHP even with no enemies

Reproduction Steps

  1. Enter the spire and climb to at least floor 2
  2. Begin descent
  3. When descending through rooms, observe mana after clearing the last enemy in a room
  4. Expected: Mana stops being drained once all enemies are dead
  5. Actual: Mana continues to drain as spells cast into the empty room

Alternative reproduction:

  1. Use debug tools to set castProgress to a high value (e.g., 5)
  2. Enter a room with no enemies (or kill all enemies in current room)
  3. Observe: spells continue casting and draining mana despite no enemies

Suggested Fix

Add floorHP > 0 to the primary spell casting while loop condition in combat-actions.ts:

while (
  weaponCastProgress['primary'] >= 1 &&
  canAffordSpellCost(spellDef.cost, rawMana, elements) &&
  floorHP > 0 &&
  safetyCounter < MAX_CASTS_PER_TICK
)

This matches the pattern already used by the melee, golem, and DoT loops.

## Bug: Spells continue casting and draining mana after all enemies in a room are dead ### Description When descending the spire and reaching floor 1 room 0, if the player has cleared the enemy in that room, they continue to cast spells and drain mana for no reason. Once there are no enemies, spell casting should stop. This is **not limited to floor 1 room 0** — it affects **any combat room** where all enemies have been killed but the room has not yet been cleared (e.g., during the same tick where the killing blow lands, or in edge cases during descent where a room with 0 enemies is generated). --- ### Root Cause **Missing `floorHP > 0` guard on the primary spell casting loop.** In `src/lib/game/stores/combat-actions.ts`, the primary spell casting `while` loop (lines ~148–196) has this condition: ```typescript while (weaponCastProgress['primary'] >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) ``` It checks: 1. Cast progress >= 1 (ready to cast) 2. Can afford mana cost 3. Safety counter It does **NOT** check whether there are any living enemies (`floorHP > 0` or `currentRoom.enemies.some(e => e.hp > 0)`). Compare with the **melee sword loop** (line 267) which **does** have this guard: ```typescript if (equippedSwords && Object.keys(equippedSwords).length > 0 && floorHP > 0) { ``` And the **golem attack loop** (line 312): ```typescript if (activeGolems.length > 0 && floorHP > 0) { ``` And the **DoT processing** (line 335): ```typescript if (floorHP > 0) { ``` So the primary spell loop is the **only** combat damage source that lacks an enemy-presence check. ### What Happens Step-by-Step 1. Player descends to floor 1 room 0 (or any room where enemies are cleared) 2. `currentAction === 'climb'` so the combat tick runs 3. `spellDef` is truthy (player has `manaBolt` active) 4. `weaponCastProgress['primary']` accumulates past 1.0 from previous ticks 5. The `while` loop fires: cast progress >= 1 AND player has mana → **spell casts** 6. `deductSpellCost()` drains mana 7. `applyDamageToRoom()` is called — since `room.enemies.length === 0`, it falls back to direct `floorHP` subtraction (line 43–47 of `combat-damage.ts`), setting `floorHP` to 0 and returning `roomCleared: true` 8. `advanceRoomOrFloor()` is called, moving to the next room 9. **But** if `weaponCastProgress['primary']` was >= 2 (multiple casts queued), the loop continues casting into the **new room** without checking if that room has enemies 10. Even in a room with 0 enemies (e.g., a non-combat room or an empty room), the loop keeps draining mana ### The `applyDamageToRoom` Empty-Enemies Fallback Makes It Worse In `combat-damage.ts` lines 43–47: ```typescript if (!room || room.enemies.length === 0) { // No enemies in room — fall back to direct floorHP subtraction const newFloorHP = Math.max(0, state.floorHP - dmg); set({ floorHP: newFloorHP }); return { floorHP: newFloorHP, floorMaxHP: state.floorMaxHP, roomCleared: newFloorHP <= 0 }; } ``` When enemies array is empty, damage is still applied to `floorHP` directly, and `roomCleared` returns `true` when `floorHP <= 0`. This means: - Spells keep "hitting" nothing - Mana keeps being drained - `roomCleared` triggers `advanceRoomOrFloor()` repeatedly - Each advancement resets `weaponCastProgress['primary']` to 0, but the loop may have already queued multiple casts ### Is This an Issue in Non-Combat Rooms? **Yes, partially.** Non-combat rooms (library, recovery, treasure, puzzle) have `enemies: []` by design. However, the non-combat room tick path in `gameStore.ts` (line 295–307) only runs when `roomType` is one of those types, and the combat tick path only runs when `currentAction === 'climb'`. The issue is that: 1. If a non-combat room is entered but `currentAction` is still `'climb'`, the combat tick still runs 2. The spell loop fires with no enemies, draining mana 3. `applyDamageToRoom` with empty enemies applies damage to `floorHP` (which is 0 for non-combat rooms), triggering `roomCleared: true` 4. This can cause premature advancement out of the non-combat room However, in practice the non-combat room tick handler in `gameStore.ts` (line 295) runs **after** the combat tick, so the combat tick may advance the room before the non-combat handler gets a chance to process it. ### Severity Assessment | Factor | Assessment | |--------|-----------| | **Impact** | High — mana is wasted on empty rooms, potentially leaving the player without mana for actual combat | | **Scope** | All combat rooms where enemies are killed; any room with empty enemies array | | **Frequency** | Every time a room is cleared, the remaining cast progress fires into the next room or into nothing | | **Player-facing** | Visible as mana draining rapidly with no enemies present; confusing and wasteful | ### Files Involved | File | Line(s) | Role | |------|---------|------| | `src/lib/game/stores/combat-actions.ts` | ~148–196 | Primary spell casting loop — **missing `floorHP > 0` guard** | | `src/lib/game/stores/combat-actions.ts` | 267 | Melee loop — has `floorHP > 0` guard (correct) | | `src/lib/game/stores/combat-actions.ts` | 312 | Golem loop — has `floorHP > 0` guard (correct) | | `src/lib/game/stores/combat-actions.ts` | 335 | DoT processing — has `floorHP > 0` guard (correct) | | `src/lib/game/stores/combat-damage.ts` | 43–47 | `applyDamageToRoom` empty-enemies fallback — applies damage to `floorHP` even with no enemies | ### Reproduction Steps 1. Enter the spire and climb to at least floor 2 2. Begin descent 3. When descending through rooms, observe mana after clearing the last enemy in a room 4. **Expected**: Mana stops being drained once all enemies are dead 5. **Actual**: Mana continues to drain as spells cast into the empty room **Alternative reproduction:** 1. Use debug tools to set `castProgress` to a high value (e.g., 5) 2. Enter a room with no enemies (or kill all enemies in current room) 3. Observe: spells continue casting and draining mana despite no enemies ### Suggested Fix Add `floorHP > 0` to the primary spell casting `while` loop condition in `combat-actions.ts`: ```typescript while ( weaponCastProgress['primary'] >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && floorHP > 0 && safetyCounter < MAX_CASTS_PER_TICK ) ``` This matches the pattern already used by the melee, golem, and DoT loops.
Anexim added the ai:todo label 2026-06-10 10:03:22 +02:00
n8n-gitea was assigned by Anexim 2026-06-10 10:03:22 +02:00
Anexim added ai:in-progress and removed ai:todo labels 2026-06-10 12:01:09 +02:00
Author
Owner

Starting work on Issue #346. Root cause confirmed: primary spell casting while loop in combat-actions.ts is missing floorHP > 0 guard. The melee, golem, and DoT loops all have it — only the primary spell loop lacks it. Also fixing the equipment spell loop which has the same issue.

Starting work on Issue #346. Root cause confirmed: primary spell casting while loop in combat-actions.ts is missing `floorHP > 0` guard. The melee, golem, and DoT loops all have it — only the primary spell loop lacks it. Also fixing the equipment spell loop which has the same issue.
Anexim added ai:done and removed ai:in-progress labels 2026-06-10 12:06:22 +02:00
Author
Owner

Fixed Issue #346. Added floorHP > 0 guard to both the primary spell casting while loop and the equipment spell casting while loop in combat-actions.ts. This matches the pattern already used by melee, golem, and DoT loops. All 17 existing tests pass + 5 new regression tests added.

✅ Fixed Issue #346. Added `floorHP > 0` guard to both the primary spell casting while loop and the equipment spell casting while loop in combat-actions.ts. This matches the pattern already used by melee, golem, and DoT loops. All 17 existing tests pass + 5 new regression tests added.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Anexim/Mana-Loop#346