From 62979ea4c739b5caa2f2f55755260d1e4bcf4938 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Wed, 10 Jun 2026 12:48:18 +0200 Subject: [PATCH] fix: #346 #345 spell casting guards and equipment spell wiring 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 --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 11 +- docs/project-structure.txt | 1 + .../spell-cast-floorhp-guard.test.ts | 241 ++++++++++++++++++ src/lib/game/stores/combat-actions.ts | 11 +- src/lib/game/stores/combat-damage.ts | 30 ++- src/lib/game/stores/gameStore.ts | 14 +- 7 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 src/lib/game/__tests__/spell-cast-floorhp-guard.test.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 474e67f..9f7f735 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -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 diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 07dd8c3..1539071 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -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", diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 52b9d03..e3a8abd 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -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 diff --git a/src/lib/game/__tests__/spell-cast-floorhp-guard.test.ts b/src/lib/game/__tests__/spell-cast-floorhp-guard.test.ts new file mode 100644 index 0000000..c6a9a9d --- /dev/null +++ b/src/lib/game/__tests__/spell-cast-floorhp-guard.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, + onDamageDealt?: (dmg: number) => { rawMana: number; elements: Record; 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); + }); +}); diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts index 65f2370..3bc677f 100644 --- a/src/lib/game/stores/combat-actions.ts +++ b/src/lib/game/stores/combat-actions.ts @@ -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); diff --git a/src/lib/game/stores/combat-damage.ts b/src/lib/game/stores/combat-damage.ts index a51244d..daed617 100644 --- a/src/lib/game/stores/combat-damage.ts +++ b/src/lib/game/stores/combat-damage.ts @@ -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, +): { rawMana: number; elements: Record } { + 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). diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index e2dbb2c..399933f 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -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()( 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,