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
+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,