fix: #346 #345 spell casting guards and equipment spell wiring
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m4s

Issue #346: Added floorHP > 0 guard to primary and equipment spell casting
while loops in combat-actions.ts. Previously spells continued casting and
draining mana after all enemies were dead.

Issue #345 Bug A: Populated equipmentSpellStates from equipped gear in
gameStore.ts combat tick setup using getActiveEquipmentSpells(). Previously
equipment spell enchantments (e.g., spell_manaBolt on casters) never fired
during combat because equipmentSpellStates was always empty.

Issue #345 Bug B: Added deductWeaponEnchantCosts() helper in combat-damage.ts
and wired it into the melee loop in combat-actions.ts. Weapon enchant spells
(fireBlade, frostBlade, lightningBlade, voidBlade) now properly deduct mana
per melee hit instead of providing free elemental bonus damage.

All 1141 tests pass (65 test files), no regressions. Added 5 new regression tests.

Files changed:
- combat-actions.ts: +floorHP>0 guards, weapon enchant cost deduction
- combat-damage.ts: +deductWeaponEnchantCosts() helper
- gameStore.ts: +equipment spell state population from equipped gear
- spell-cast-floorhp-guard.test.ts: new regression test file
This commit is contained in:
2026-06-10 12:48:18 +02:00
parent 48eee17d43
commit 62979ea4c7
7 changed files with 302 additions and 8 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Circular Dependencies
Generated: 2026-06-10T09:19:26.381Z
Generated: 2026-06-10T09:41:41.471Z
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
+9 -2
View File
@@ -1,6 +1,6 @@
{
"_meta": {
"generated": "2026-06-10T09:19:24.358Z",
"generated": "2026-06-10T09:41:39.446Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
},
@@ -564,6 +564,7 @@
"effects/discipline-effects.ts",
"stores/attunementStore.ts",
"stores/combat-state.types.ts",
"stores/discipline-slice.ts",
"stores/golem-combat-actions.ts",
"stores/manaStore.ts",
"stores/non-combat-room-actions.ts",
@@ -578,6 +579,7 @@
"stores/combat-actions.ts",
"stores/combat-descent-actions.ts",
"stores/combat-state.types.ts",
"stores/discipline-slice.ts",
"stores/golemancy-actions.ts",
"stores/non-combat-room-actions.ts",
"types.ts",
@@ -692,7 +694,6 @@
],
"stores/gameStore.ts": [
"constants.ts",
"data/attunements.ts",
"data/guardian-encounters.ts",
"effects.ts",
"effects/discipline-effects.ts",
@@ -713,6 +714,7 @@
"stores/tick-pipeline.ts",
"stores/uiStore.ts",
"types.ts",
"utils/conversion-params.ts",
"utils/conversion-rates.ts",
"utils/element-cap-bonus.ts",
"utils/element-distance.ts",
@@ -872,6 +874,10 @@
"types.ts",
"utils/mana-utils.ts"
],
"utils/conversion-params.ts": [
"data/attunements.ts",
"data/guardian-encounters.ts"
],
"utils/conversion-rates.ts": [
"data/conversion-costs.ts",
"effects/discipline-effects.ts",
@@ -902,6 +908,7 @@
],
"utils/index.ts": [
"utils/combat-utils.ts",
"utils/conversion-params.ts",
"utils/floor-utils.ts",
"utils/formatting.ts",
"utils/mana-utils.ts",
+1
View File
@@ -237,6 +237,7 @@ Mana-Loop/
│ │ │ │ ├── regression-fixes.test.ts
│ │ │ │ ├── room-utils-floor-state.test.ts
│ │ │ │ ├── room-utils.test.ts
│ │ │ │ ├── spell-cast-floorhp-guard.test.ts
│ │ │ │ ├── spire-utils.test.ts
│ │ │ │ ├── store-actions-combat-prestige.test.ts
│ │ │ │ ├── store-actions-discipline.test.ts
@@ -0,0 +1,241 @@
// ─── Regression Test: Spell casting must stop when all enemies are dead ────────
// Issue #346: Spells continue casting and draining mana after all enemies are dead
//
// This test verifies that the primary spell casting while loop and the equipment
// spell casting while loop both respect the floorHP > 0 guard, matching the
// pattern already used by the melee, golem, and DoT loops.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { processCombatTick } from '../stores/combat-actions';
import { useCombatStore } from '../stores/combatStore';
import { useManaStore, makeInitialElements } from '../stores/manaStore';
import { useGameStore } from '../stores/gameStore';
import { usePrestigeStore } from '../stores/prestigeStore';
import { useUIStore } from '../stores/uiStore';
import { useDisciplineStore } from '../stores/discipline-slice';
import { getFloorMaxHP } from '../utils';
import type { CombatTickResult } from '../stores/combat-actions';
import type { CombatState } from '../stores/combat-state.types';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeStoreInitialSpells() {
return { manaBolt: { learned: true, level: 1, studyProgress: 0 } };
}
function makeDefaultRoom(enemyHP: number): CombatState['currentRoom'] {
return {
roomType: 'combat',
enemies: [{
id: 'enemy', name: 'Test Enemy', hp: enemyHP, maxHP: Math.max(enemyHP, 100),
armor: 0, dodgeChance: 0, barrier: 0, element: 'fire', activeEffects: [], effectiveArmor: 0,
}],
};
}
function resetStores() {
useUIStore.setState({ paused: false, gameOver: false, victory: false, logs: [] });
useGameStore.setState({ day: 1, hour: 0, incursionStrength: 0, containmentWards: 0, initialized: true });
useManaStore.setState({ rawMana: 1000, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(500, {}) });
useCombatStore.setState({
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
maxFloorReached: 1,
activeSpell: 'manaBolt',
currentAction: 'climb',
castProgress: 0,
spireMode: false,
currentRoom: makeDefaultRoom(getFloorMaxHP(1)),
clearedFloors: {},
climbDirection: 'up',
isDescending: false,
startFloor: 1,
exitFloor: 1,
currentRoomIndex: 0,
roomsPerFloor: 5,
descentPeak: null,
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
weaponCastProgress: {},
meleeSwordProgress: {},
guardianShield: 0,
guardianShieldMax: 0,
guardianBarrier: 0,
guardianBarrierMax: 0,
spells: makeStoreInitialSpells(),
activityLog: [],
achievements: { unlocked: [], progress: {} },
totalSpellsCast: 0,
totalDamageDealt: 0,
totalCraftsCompleted: 0,
});
usePrestigeStore.setState({
loopCount: 0, insight: 0, totalInsight: 0, loopInsight: 0,
prestigeUpgrades: {}, pactSlots: 1, defeatedGuardians: [],
signedPacts: [], signedPactDetails: {},
pactRitualFloor: null, pactRitualProgress: 0,
});
useDisciplineStore.setState({ disciplines: {}, activeIds: [], concurrentLimit: 1, totalXP: 0, processedPerks: [] });
}
function golemApplyDamageToRoom(dmg: number): { floorHP: number; floorMaxHP: number; roomCleared: boolean } {
const cs = useCombatStore.getState();
const newFloorHP = Math.max(0, cs.floorHP - dmg);
return { floorHP: newFloorHP, floorMaxHP: cs.floorMaxHP, roomCleared: newFloorHP <= 0 };
}
function runCombatTick(
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
onDamageDealt?: (dmg: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }>; modifiedDamage?: number },
): CombatTickResult {
return processCombatTick(
() => useCombatStore.getState(),
(partial) => useCombatStore.setState(partial),
rawMana,
elements,
1000, // maxMana
1, // attackSpeedMult
vi.fn(), // onFloorCleared
onDamageDealt || ((dmg) => ({ rawMana, elements, modifiedDamage: dmg })),
[], // signedPacts
{ activeGolems: [] },
golemApplyDamageToRoom,
(dmg: number) => dmg, // applyEnemyDefenses passthrough
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// REGRESSION: floorHP > 0 guard on spell casting loops
// ═══════════════════════════════════════════════════════════════════════════════
describe('Issue #346: Spell casting stops when enemies are dead', () => {
beforeEach(resetStores);
it('should NOT cast spells or drain mana when floorHP is 0 (all enemies dead)', () => {
// Set floorHP to 0 (all enemies dead) and queue multiple casts
useCombatStore.setState({
floorHP: 0,
floorMaxHP: 100,
currentRoom: {
roomType: 'combat',
enemies: [{
id: 'enemy', name: 'Dead Enemy', hp: 0, maxHP: 100,
armor: 0, dodgeChance: 0, barrier: 0, element: 'fire', activeEffects: [], effectiveArmor: 0,
}],
},
weaponCastProgress: { primary: 5 },
});
// Track how many times onDamageDealt is called (each call = one spell cast)
let castCount = 0;
const elements = makeInitialElements(500, {});
const startMana = 1000;
const result = runCombatTick(startMana, elements, (_dmg) => {
castCount++;
return { rawMana: startMana, elements, modifiedDamage: _dmg };
});
// No spells should have fired — onDamageDealt should never be called
expect(castCount).toBe(0);
// floorHP should remain 0
expect(result.floorHP).toBe(0);
});
it('should NOT cast spells or drain mana when room has empty enemies array', () => {
useCombatStore.setState({
floorHP: 0,
floorMaxHP: 100,
currentRoom: { roomType: 'combat', enemies: [] },
weaponCastProgress: { primary: 3 },
});
let castCount = 0;
const elements = makeInitialElements(500, {});
const startMana = 1000;
runCombatTick(startMana, elements, (_dmg) => {
castCount++;
return { rawMana: startMana, elements, modifiedDamage: _dmg };
});
expect(castCount).toBe(0);
});
it('should cast spells normally when floorHP > 0 (enemies alive)', () => {
const floorHP = getFloorMaxHP(1);
useCombatStore.setState({
floorHP,
floorMaxHP: floorHP,
currentRoom: makeDefaultRoom(floorHP),
weaponCastProgress: { primary: 2 },
});
let castCount = 0;
const elements = makeInitialElements(500, {});
const startMana = 1000;
const result = runCombatTick(startMana, elements, (_dmg) => {
castCount++;
return { rawMana: startMana, elements, modifiedDamage: _dmg };
});
// Spells SHOULD have fired when enemies are alive
expect(castCount).toBeGreaterThan(0);
// floorHP should have decreased (damage was dealt)
expect(result.floorHP).toBeLessThan(floorHP);
});
it('should stop casting mid-loop when floorHP reaches 0 during the tick', () => {
// First cast will kill the enemy (floorHP → 0), remaining queued casts should NOT fire
useCombatStore.setState({
floorHP: 1,
floorMaxHP: 100,
currentRoom: makeDefaultRoom(1),
weaponCastProgress: { primary: 5 },
});
let castCount = 0;
const elements = makeInitialElements(500, {});
const startMana = 1000;
runCombatTick(startMana, elements, (_dmg) => {
castCount++;
return { rawMana: startMana, elements, modifiedDamage: _dmg };
});
// Only 1 cast should fire (killing the enemy), not all 5
expect(castCount).toBe(1);
});
it('should NOT drain mana from equipment spells when floorHP is 0', () => {
useCombatStore.setState({
floorHP: 0,
floorMaxHP: 100,
currentRoom: { roomType: 'combat', enemies: [] },
weaponCastProgress: { primary: 0 },
equipmentSpellStates: [
{ spellId: 'manaBolt', castProgress: 3, sourceInstanceId: 'test-staff' },
],
});
let equipmentCastCount = 0;
const elements = makeInitialElements(500, {});
const startMana = 1000;
runCombatTick(startMana, elements, (dmg) => {
// Track if equipment spells fire by checking if cast progress was consumed
const cs = useCombatStore.getState();
if (cs.equipmentSpellStates.length > 0 && cs.equipmentSpellStates[0].castProgress < 3) {
equipmentCastCount++;
}
return { rawMana: startMana, elements, modifiedDamage: dmg };
});
// Equipment spells should not have changed cast progress
const finalState = useCombatStore.getState();
expect(finalState.equipmentSpellStates[0].castProgress).toBe(3);
});
});
+8 -3
View File
@@ -15,7 +15,7 @@ import {
processGolemManaRegen,
} from './golem-combat-actions';
import { processGolemAttacksFromStore } from './golem-combat-helpers';
import { applyDamageToRoom } from './combat-damage';
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
// ─── Result Type ───────────────────────────────────────────────────────────────
@@ -144,7 +144,7 @@ export function processCombatTick(
// Process complete casts for active spell
let safetyCounter = 0;
const MAX_CASTS_PER_TICK = 100;
while (weaponCastProgress['primary'] >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) {
while (weaponCastProgress['primary'] >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && floorHP > 0 && safetyCounter < MAX_CASTS_PER_TICK) {
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
rawMana = afterCost.rawMana;
elements = afterCost.elements;
@@ -208,7 +208,7 @@ export function processCombatTick(
let eSafetyCounter = 0;
const MAX_E_CASTS_PER_TICK = 100;
while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements) && eSafetyCounter < MAX_E_CASTS_PER_TICK) {
while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements) && floorHP > 0 && eSafetyCounter < MAX_E_CASTS_PER_TICK) {
const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements);
rawMana = eAfterCost.rawMana;
elements = eAfterCost.elements;
@@ -274,6 +274,11 @@ export function processCombatTick(
let meleeSafetyCounter = 0;
while (meleeProgress >= 1 && meleeSafetyCounter < 100) {
const meleeDamage = calcMeleeDamage(swordInstance as EquipmentInstance, swordType, floorElems[0]);
// Deduct mana cost for weapon enchant spells (fireBlade, frostBlade, etc.)
const enchantCost = deductWeaponEnchantCosts(swordInstance as EquipmentInstance, rawMana, elements);
rawMana = enchantCost.rawMana;
elements = enchantCost.elements;
// Get the current target enemy (lowest HP, matching focus-fire targeting in applyDamageToRoom)
const currentRoomState = get().currentRoom;
const livingEnemies = currentRoomState.enemies.filter(e => e.hp > 0);
+29 -1
View File
@@ -1,8 +1,36 @@
// ─── Per-Enemy Damage Application (spec §3.2) ─────────────────────────────────
// Extracted from combat-actions.ts to stay under the 400-line file limit.
import { ENCHANTMENT_EFFECTS, ENCHANTMENT_SPELLS } from '../data/enchantment-effects';
import { canAffordSpellCost, deductSpellCost } from '../utils';
import type { CombatStore, CombatState } from './combat-state.types';
import type { EnemyState } from '../types';
import type { EnemyState, EquipmentInstance } from '../types';
/**
* Deduct mana costs for weapon enchant spells on a sword (fireBlade, frostBlade, etc.).
* Called once per melee hit in the combat tick.
* Returns updated { rawMana, elements } reflecting any enchant costs.
*/
export function deductWeaponEnchantCosts(
swordInstance: EquipmentInstance,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
let rm = rawMana;
let el = elements;
for (const ench of swordInstance.enchantments) {
const enchantEffect = ENCHANTMENT_EFFECTS[ench.effectId];
if (enchantEffect?.effect.type === 'special' && enchantEffect.effect.specialId) {
const spellDef = ENCHANTMENT_SPELLS[enchantEffect.effect.specialId];
if (spellDef?.cost) {
const afterCost = deductSpellCost(spellDef.cost, rm, el);
rm = afterCost.rawMana;
el = afterCost.elements;
}
}
}
return { rawMana: rm, elements: el };
}
/**
* Find the enemy with the lowest current HP in the room (focus-fire targeting).
+13 -1
View File
@@ -6,7 +6,7 @@ import { computeEquipmentEffects } from '../effects';
import type { ComputedEffects } from '../effects/upgrade-effects.types';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import { computeMaxMana, computeRegen, getMeditationBonus, getIncursionStrength, calcInsight } from '../utils';
import { computeMaxMana, computeRegen, getMeditationBonus, getIncursionStrength, calcInsight, getActiveEquipmentSpells } from '../utils';
import { getElementDistance } from '../utils/element-distance';
import { computeConversionRates } from '../utils/conversion-rates';
import { mergePerElementCapBonuses } from '../utils/element-cap-bonus';
@@ -277,6 +277,18 @@ export const useGameStore = create<GameCoordinatorStore>()(
const inst = ctx.crafting.equipmentInstances?.[iid];
if (inst && EQUIPMENT_TYPES[inst.typeId]?.category === 'sword') equippedSwords[iid] = inst;
}
// Populate equipment spell states from equipped gear
const activeEquipSpells = getActiveEquipmentSpells(
ctx.crafting.equippedInstances || {},
ctx.crafting.equipmentInstances || {},
);
const equipmentSpellStates = activeEquipSpells.map((s) => ({
spellId: s.spellId,
sourceEquipment: s.equipmentId,
castProgress: 0,
}));
useCombatStore.setState({ equipmentSpellStates });
const cr = useCombatStore.getState().processCombatTick(
rawMana, elements, maxMana, 1,
combatCbs.onFloorCleared,