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,