fix: #346 #345 spell casting guards and equipment spell wiring
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m4s
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:
@@ -1,5 +1,5 @@
|
|||||||
# Circular Dependencies
|
# 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.
|
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
|
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_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.",
|
"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."
|
"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",
|
"effects/discipline-effects.ts",
|
||||||
"stores/attunementStore.ts",
|
"stores/attunementStore.ts",
|
||||||
"stores/combat-state.types.ts",
|
"stores/combat-state.types.ts",
|
||||||
|
"stores/discipline-slice.ts",
|
||||||
"stores/golem-combat-actions.ts",
|
"stores/golem-combat-actions.ts",
|
||||||
"stores/manaStore.ts",
|
"stores/manaStore.ts",
|
||||||
"stores/non-combat-room-actions.ts",
|
"stores/non-combat-room-actions.ts",
|
||||||
@@ -578,6 +579,7 @@
|
|||||||
"stores/combat-actions.ts",
|
"stores/combat-actions.ts",
|
||||||
"stores/combat-descent-actions.ts",
|
"stores/combat-descent-actions.ts",
|
||||||
"stores/combat-state.types.ts",
|
"stores/combat-state.types.ts",
|
||||||
|
"stores/discipline-slice.ts",
|
||||||
"stores/golemancy-actions.ts",
|
"stores/golemancy-actions.ts",
|
||||||
"stores/non-combat-room-actions.ts",
|
"stores/non-combat-room-actions.ts",
|
||||||
"types.ts",
|
"types.ts",
|
||||||
@@ -692,7 +694,6 @@
|
|||||||
],
|
],
|
||||||
"stores/gameStore.ts": [
|
"stores/gameStore.ts": [
|
||||||
"constants.ts",
|
"constants.ts",
|
||||||
"data/attunements.ts",
|
|
||||||
"data/guardian-encounters.ts",
|
"data/guardian-encounters.ts",
|
||||||
"effects.ts",
|
"effects.ts",
|
||||||
"effects/discipline-effects.ts",
|
"effects/discipline-effects.ts",
|
||||||
@@ -713,6 +714,7 @@
|
|||||||
"stores/tick-pipeline.ts",
|
"stores/tick-pipeline.ts",
|
||||||
"stores/uiStore.ts",
|
"stores/uiStore.ts",
|
||||||
"types.ts",
|
"types.ts",
|
||||||
|
"utils/conversion-params.ts",
|
||||||
"utils/conversion-rates.ts",
|
"utils/conversion-rates.ts",
|
||||||
"utils/element-cap-bonus.ts",
|
"utils/element-cap-bonus.ts",
|
||||||
"utils/element-distance.ts",
|
"utils/element-distance.ts",
|
||||||
@@ -872,6 +874,10 @@
|
|||||||
"types.ts",
|
"types.ts",
|
||||||
"utils/mana-utils.ts"
|
"utils/mana-utils.ts"
|
||||||
],
|
],
|
||||||
|
"utils/conversion-params.ts": [
|
||||||
|
"data/attunements.ts",
|
||||||
|
"data/guardian-encounters.ts"
|
||||||
|
],
|
||||||
"utils/conversion-rates.ts": [
|
"utils/conversion-rates.ts": [
|
||||||
"data/conversion-costs.ts",
|
"data/conversion-costs.ts",
|
||||||
"effects/discipline-effects.ts",
|
"effects/discipline-effects.ts",
|
||||||
@@ -902,6 +908,7 @@
|
|||||||
],
|
],
|
||||||
"utils/index.ts": [
|
"utils/index.ts": [
|
||||||
"utils/combat-utils.ts",
|
"utils/combat-utils.ts",
|
||||||
|
"utils/conversion-params.ts",
|
||||||
"utils/floor-utils.ts",
|
"utils/floor-utils.ts",
|
||||||
"utils/formatting.ts",
|
"utils/formatting.ts",
|
||||||
"utils/mana-utils.ts",
|
"utils/mana-utils.ts",
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── regression-fixes.test.ts
|
│ │ │ │ ├── regression-fixes.test.ts
|
||||||
│ │ │ │ ├── room-utils-floor-state.test.ts
|
│ │ │ │ ├── room-utils-floor-state.test.ts
|
||||||
│ │ │ │ ├── room-utils.test.ts
|
│ │ │ │ ├── room-utils.test.ts
|
||||||
|
│ │ │ │ ├── spell-cast-floorhp-guard.test.ts
|
||||||
│ │ │ │ ├── spire-utils.test.ts
|
│ │ │ │ ├── spire-utils.test.ts
|
||||||
│ │ │ │ ├── store-actions-combat-prestige.test.ts
|
│ │ │ │ ├── store-actions-combat-prestige.test.ts
|
||||||
│ │ │ │ ├── store-actions-discipline.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
processGolemManaRegen,
|
processGolemManaRegen,
|
||||||
} from './golem-combat-actions';
|
} from './golem-combat-actions';
|
||||||
import { processGolemAttacksFromStore } from './golem-combat-helpers';
|
import { processGolemAttacksFromStore } from './golem-combat-helpers';
|
||||||
import { applyDamageToRoom } from './combat-damage';
|
import { applyDamageToRoom, deductWeaponEnchantCosts } from './combat-damage';
|
||||||
|
|
||||||
// ─── Result Type ───────────────────────────────────────────────────────────────
|
// ─── Result Type ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ export function processCombatTick(
|
|||||||
// Process complete casts for active spell
|
// Process complete casts for active spell
|
||||||
let safetyCounter = 0;
|
let safetyCounter = 0;
|
||||||
const MAX_CASTS_PER_TICK = 100;
|
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);
|
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
|
||||||
rawMana = afterCost.rawMana;
|
rawMana = afterCost.rawMana;
|
||||||
elements = afterCost.elements;
|
elements = afterCost.elements;
|
||||||
@@ -208,7 +208,7 @@ export function processCombatTick(
|
|||||||
|
|
||||||
let eSafetyCounter = 0;
|
let eSafetyCounter = 0;
|
||||||
const MAX_E_CASTS_PER_TICK = 100;
|
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);
|
const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements);
|
||||||
rawMana = eAfterCost.rawMana;
|
rawMana = eAfterCost.rawMana;
|
||||||
elements = eAfterCost.elements;
|
elements = eAfterCost.elements;
|
||||||
@@ -274,6 +274,11 @@ export function processCombatTick(
|
|||||||
let meleeSafetyCounter = 0;
|
let meleeSafetyCounter = 0;
|
||||||
while (meleeProgress >= 1 && meleeSafetyCounter < 100) {
|
while (meleeProgress >= 1 && meleeSafetyCounter < 100) {
|
||||||
const meleeDamage = calcMeleeDamage(swordInstance as EquipmentInstance, swordType, floorElems[0]);
|
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)
|
// Get the current target enemy (lowest HP, matching focus-fire targeting in applyDamageToRoom)
|
||||||
const currentRoomState = get().currentRoom;
|
const currentRoomState = get().currentRoom;
|
||||||
const livingEnemies = currentRoomState.enemies.filter(e => e.hp > 0);
|
const livingEnemies = currentRoomState.enemies.filter(e => e.hp > 0);
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
// ─── Per-Enemy Damage Application (spec §3.2) ─────────────────────────────────
|
// ─── Per-Enemy Damage Application (spec §3.2) ─────────────────────────────────
|
||||||
// Extracted from combat-actions.ts to stay under the 400-line file limit.
|
// 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 { 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).
|
* Find the enemy with the lowest current HP in the room (focus-fire targeting).
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { computeEquipmentEffects } from '../effects';
|
|||||||
import type { ComputedEffects } from '../effects/upgrade-effects.types';
|
import type { ComputedEffects } from '../effects/upgrade-effects.types';
|
||||||
|
|
||||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
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 { getElementDistance } from '../utils/element-distance';
|
||||||
import { computeConversionRates } from '../utils/conversion-rates';
|
import { computeConversionRates } from '../utils/conversion-rates';
|
||||||
import { mergePerElementCapBonuses } from '../utils/element-cap-bonus';
|
import { mergePerElementCapBonuses } from '../utils/element-cap-bonus';
|
||||||
@@ -277,6 +277,18 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
const inst = ctx.crafting.equipmentInstances?.[iid];
|
const inst = ctx.crafting.equipmentInstances?.[iid];
|
||||||
if (inst && EQUIPMENT_TYPES[inst.typeId]?.category === 'sword') equippedSwords[iid] = inst;
|
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(
|
const cr = useCombatStore.getState().processCombatTick(
|
||||||
rawMana, elements, maxMana, 1,
|
rawMana, elements, maxMana, 1,
|
||||||
combatCbs.onFloorCleared,
|
combatCbs.onFloorCleared,
|
||||||
|
|||||||
Reference in New Issue
Block a user