From 098ec861893ddf72cd2a607d28607f7187785b83 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Mon, 8 Jun 2026 18:25:05 +0200 Subject: [PATCH] fix: spire combat 11 high-severity discrepancies (issue #333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-01: Implement per-weapon cast progress (weaponCastProgress record) D-04: Bypass Executioner/Berserker discipline specials for golem attacks D-09: Fix lightning counter direction (lightning→water, not lightning→earth) D-10: Add full composite element counters (blackflame/radiantflames ↔ frost/water/light/dark) D-15: Fix Executioner to check per-enemy HP < 25% instead of floorHP ratio D-20: Fix dodge formula to match spec (min(0.55, floor × 0.003), starts at 0) D-22: Fix shield modifier to use flat HP pool instead of percentage barrier D-23: Wire up applyMageBarrierRecharge in the damage pipeline D-25: Move guardian regen from per-damage-event to once-per-tick D-26: Add guardian armor reduction to the guardian defensive pipeline D-31: Fix armor_corrode to be temporary (restore armor on effect expiry) D-38: Implement AoE damage distribution across enemies All 1069 tests pass. No files exceed 400 lines. --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 2 +- .../game/tabs/SpireSummaryTab.test.ts | 16 ++-- .../game/__tests__/cross-module-helpers.ts | 1 + src/lib/game/__tests__/enemy-defenses.test.ts | 1 + .../game/__tests__/enemy-generator.test.ts | 3 +- .../game/__tests__/melee-auto-attack.test.ts | 9 +- .../__tests__/melee-defense-bypass.test.ts | 1 + src/lib/game/constants/elements.ts | 34 +++++-- src/lib/game/stores/combat-actions.ts | 23 +++-- src/lib/game/stores/combat-damage.ts | 20 +++- src/lib/game/stores/combat-descent-actions.ts | 7 +- src/lib/game/stores/combat-state.types.ts | 6 +- src/lib/game/stores/combatStore.ts | 7 +- src/lib/game/stores/dot-runtime.ts | 11 ++- src/lib/game/stores/gameStore.ts | 4 +- src/lib/game/stores/golem-combat-actions.ts | 6 +- src/lib/game/stores/pipelines/combat-tick.ts | 95 +++++++++++++++---- src/lib/game/types/game.ts | 3 +- src/lib/game/utils/combat-utils.ts | 13 +-- src/lib/game/utils/enemy-generator.ts | 14 ++- 21 files changed, 203 insertions(+), 75 deletions(-) diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index e5a9ab7..9fc8017 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,5 +1,5 @@ # Circular Dependencies -Generated: 2026-06-08T13:51:45.536Z +Generated: 2026-06-08T14:03:03.873Z Found: 1 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 95cc0d4..ca777e7 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-08T13:51:43.381Z", + "generated": "2026-06-08T14:03:01.818Z", "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." }, diff --git a/src/components/game/tabs/SpireSummaryTab.test.ts b/src/components/game/tabs/SpireSummaryTab.test.ts index 0bedc03..beca472 100644 --- a/src/components/game/tabs/SpireSummaryTab.test.ts +++ b/src/components/game/tabs/SpireSummaryTab.test.ts @@ -96,13 +96,15 @@ describe('Floor element cycle', () => { it('element opposites define expected pairs', async () => { const { ELEMENT_OPPOSITES } = await import('@/lib/game/constants'); - expect(ELEMENT_OPPOSITES['fire']).toBe('water'); - expect(ELEMENT_OPPOSITES['water']).toBe('fire'); - expect(ELEMENT_OPPOSITES['air']).toBe('earth'); - expect(ELEMENT_OPPOSITES['earth']).toBe('air'); - expect(ELEMENT_OPPOSITES['light']).toBe('dark'); - expect(ELEMENT_OPPOSITES['dark']).toBe('light'); - expect(ELEMENT_OPPOSITES['lightning']).toBe('earth'); + // ELEMENT_OPPOSITES now uses arrays to support multiple counters per element + expect(ELEMENT_OPPOSITES['fire']).toEqual(expect.arrayContaining(['water'])); + expect(ELEMENT_OPPOSITES['water']).toEqual(expect.arrayContaining(['fire', 'lightning'])); + expect(ELEMENT_OPPOSITES['air']).toEqual(expect.arrayContaining(['earth'])); + expect(ELEMENT_OPPOSITES['earth']).toEqual(expect.arrayContaining(['air'])); + expect(ELEMENT_OPPOSITES['light']).toEqual(expect.arrayContaining(['dark'])); + expect(ELEMENT_OPPOSITES['dark']).toEqual(expect.arrayContaining(['light'])); + // lightning is weak to earth (earth counters lightning, spec §4.2) + expect(ELEMENT_OPPOSITES['lightning']).toEqual(expect.arrayContaining(['earth'])); }); }); diff --git a/src/lib/game/__tests__/cross-module-helpers.ts b/src/lib/game/__tests__/cross-module-helpers.ts index 6f9fd7b..9924bb9 100644 --- a/src/lib/game/__tests__/cross-module-helpers.ts +++ b/src/lib/game/__tests__/cross-module-helpers.ts @@ -54,6 +54,7 @@ export function resetAllStores() { isDescentComplete: false, golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] }, equipmentSpellStates: [], + weaponCastProgress: {}, comboHitCount: 0, floorHitCount: 0, spells: makeInitialSpells(), diff --git a/src/lib/game/__tests__/enemy-defenses.test.ts b/src/lib/game/__tests__/enemy-defenses.test.ts index 1025246..db515b8 100644 --- a/src/lib/game/__tests__/enemy-defenses.test.ts +++ b/src/lib/game/__tests__/enemy-defenses.test.ts @@ -41,6 +41,7 @@ function resetStores() { isDescentComplete: false, golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: {}, golemLoadout: [] }, equipmentSpellStates: [], + weaponCastProgress: {}, comboHitCount: 0, floorHitCount: 0, spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } }, diff --git a/src/lib/game/__tests__/enemy-generator.test.ts b/src/lib/game/__tests__/enemy-generator.test.ts index 5328b6a..4a182c5 100644 --- a/src/lib/game/__tests__/enemy-generator.test.ts +++ b/src/lib/game/__tests__/enemy-generator.test.ts @@ -72,7 +72,8 @@ describe('generateEnemy', () => { it('should apply shield modifier', () => { const enemy = generateEnemy(30, ['shield']); expect(enemy.modifiers).toContain('shield'); - expect(enemy.barrier).toBeGreaterThanOrEqual(SHIELD_AMOUNT); + // Shield modifier now sets shieldPool (flat HP) instead of barrier (percentage) + expect(enemy.shieldPool).toBeGreaterThanOrEqual(enemy.maxHP * SHIELD_AMOUNT); }); it('should have valid element', () => { diff --git a/src/lib/game/__tests__/melee-auto-attack.test.ts b/src/lib/game/__tests__/melee-auto-attack.test.ts index 50220e7..e0e13e7 100644 --- a/src/lib/game/__tests__/melee-auto-attack.test.ts +++ b/src/lib/game/__tests__/melee-auto-attack.test.ts @@ -147,13 +147,14 @@ describe('calcMeleeDamage', () => { expect(damage).toBe(5); }); - it('should handle frost-enchanted sword vs fire enemy (frost weak to fire = 0.75x)', () => { + it('should handle frost-enchanted sword vs fire enemy (frost super effective vs fire = 1.5x, spec §4.2 bidirectional)', () => { const swordType = { stats: { baseDamage: 15, attackSpeed: 1.0 } }; const swordInstance = makeSwordInstance('crystalBlade', [ { effectId: 'sword_frost', stacks: 1, actualCost: 40 }, ]); const damage = calcMeleeDamage(swordInstance, swordType, 'fire'); - expect(damage).toBe(15 * 0.75); + // Frost ↔ fire is bidirectional: frost counters fire (super effective = 1.5x) + expect(damage).toBe(15 * 1.5); }); it('should handle lightning-enchanted sword vs earth enemy (lightning weak to earth = 0.75x)', () => { @@ -189,7 +190,7 @@ describe('melee auto-attack in processCombatTick', () => { const elements = makeInitialElements(500, {}); const sword = makeSwordInstance('ironBlade'); const equippedSwords = { [sword.instanceId]: sword }; - let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100, floorMaxHP: 100, maxFloorReached: 1, castProgress: 0, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {}, currentRoom: { roomType: 'combat', enemies: [] } }; + let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100, floorMaxHP: 100, maxFloorReached: 1, castProgress: 0, weaponCastProgress: {}, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {}, currentRoom: { roomType: 'combat', enemies: [] } }; for (let i = 0; i < 10; i++) { result = runCombatTickWithSwords(1000, elements, equippedSwords); } @@ -221,7 +222,7 @@ describe('melee auto-attack in processCombatTick', () => { const sword = makeSwordInstance('ironBlade'); sword.instanceId = 'test-sword'; const equippedSwords = { [sword.instanceId]: sword }; - let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100000, floorMaxHP: 100000, maxFloorReached: 1, castProgress: 0, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {}, currentRoom: { roomType: 'combat', enemies: [] } }; + let result: CombatTickResult = { rawMana: 1000, elements, logMessages: [], totalManaGathered: 0, currentFloor: 1, floorHP: 100000, floorMaxHP: 100000, maxFloorReached: 1, castProgress: 0, weaponCastProgress: {}, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: {}, currentRoom: { roomType: 'combat', enemies: [] } }; // Run 25 ticks: each tick adds 0.048 progress, so 25 * 0.048 = 1.2, triggering a hit for (let i = 0; i < 25; i++) { result = runCombatTickWithSwords(1000, elements, equippedSwords); diff --git a/src/lib/game/__tests__/melee-defense-bypass.test.ts b/src/lib/game/__tests__/melee-defense-bypass.test.ts index 557aac7..05c5ac0 100644 --- a/src/lib/game/__tests__/melee-defense-bypass.test.ts +++ b/src/lib/game/__tests__/melee-defense-bypass.test.ts @@ -159,6 +159,7 @@ function runTicksAndMeasureDamage( floorMaxHP: initialFloorHP, maxFloorReached: 1, castProgress: 0, + weaponCastProgress: {}, equipmentSpellStates: [], activeGolems: [], meleeSwordProgress: useCombatStore.getState().meleeSwordProgress, diff --git a/src/lib/game/constants/elements.ts b/src/lib/game/constants/elements.ts index ce5361c..3b1e6d3 100644 --- a/src/lib/game/constants/elements.ts +++ b/src/lib/game/constants/elements.ts @@ -53,16 +53,30 @@ export const ELEMENTS: Record = { export const FLOOR_ELEM_CYCLE = ["fire", "water", "air", "earth", "light", "dark", "death"]; // ─── Element Opposites for Damage Calculation ──────────────────────────────── -export const ELEMENT_OPPOSITES: Record = { - fire: 'water', water: 'fire', - air: 'earth', earth: 'air', - light: 'dark', dark: 'light', - lightning: 'earth', // Lightning is weak to earth (grounding) - frost: 'fire', // Frost is weak to fire (melting) - blackflame: 'light', // BlackFlame is weak to light (purification) - radiantflames: 'dark', // Radiant Flames are weak to dark (extinguishing) - miasma: 'air', // Miasma is weak to air (dispersion) - shadowglass: 'light', // Shadow Glass is weak to light (shattering) +// Element Opposites for Damage Calculation +// Semantics: ELEMENT_OPPOSITES[X] = list of elements that X is weak to (counters of X) +// In getElementalBonus(spellElem, floorElem): +// - If spellElem is in ELEMENT_OPPOSITES[floorElem] → super effective (1.5x) +// - If floorElem is in ELEMENT_OPPOSITES[spellElem] → weak (0.75x) +// Bidirectional pairs: if A counters B, then B counters A (both can be super effective against each other) +// Directional counters: +// earth → lightning (earth counters lightning, so lightning is weak to earth) +// lightning → water (lightning counters water, so water is weak to lightning) +export const ELEMENT_OPPOSITES: Record = { + // Base opposites (bidirectional) + fire: ['water', 'frost'], water: ['fire', 'lightning', 'blackflame', 'radiantflames'], + air: ['earth', 'miasma'], earth: ['air'], + light: ['dark', 'blackflame', 'shadowglass'], dark: ['light', 'radiantflames'], + + // Directional counters + lightning: ['earth'], // lightning is weak to earth (earth counters lightning) + + // Composite element counters (bidirectional per spec §4.2) + frost: ['fire', 'blackflame', 'radiantflames'], + blackflame: ['frost', 'water', 'light'], + radiantflames: ['frost', 'water', 'dark'], + miasma: ['air'], + shadowglass: ['light'], }; // ─── Element Icon Mapping (Lucide Icons) ────────────────────────────────────── diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts index b034ed9..65f2370 100644 --- a/src/lib/game/stores/combat-actions.ts +++ b/src/lib/game/stores/combat-actions.ts @@ -38,6 +38,7 @@ function makeDefaultCombatTickResult( floorMaxHP: state.floorMaxHP, maxFloorReached: state.maxFloorReached, castProgress: state.castProgress, + weaponCastProgress: state.weaponCastProgress, equipmentSpellStates: state.equipmentSpellStates, activeGolems, meleeSwordProgress: state.meleeSwordProgress, @@ -55,6 +56,7 @@ export interface CombatTickResult { floorMaxHP: number; maxFloorReached: number; castProgress: number; + weaponCastProgress: Record; equipmentSpellStates: CombatState['equipmentSpellStates']; activeGolems: RuntimeActiveGolem[]; meleeSwordProgress: Record; @@ -71,7 +73,7 @@ export function processCombatTick( _maxMana: number, attackSpeedMult: number, onFloorCleared: (floor: number, wasGuardian: boolean) => void, - onDamageDealt: (damage: number) => { + onDamageDealt: (damage: number, skipSpecials?: boolean) => { rawMana: number; elements: Record; modifiedDamage?: number; @@ -123,7 +125,7 @@ export function processCombatTick( let floorHP = state.floorHP; let currentFloor = state.currentFloor; let floorMaxHP = state.floorMaxHP; - let castProgress = state.castProgress; + const weaponCastProgress = { ...state.weaponCastProgress }; let currentRoom = state.currentRoom; const updatedEquipmentSpellStates = [...state.equipmentSpellStates]; @@ -135,12 +137,14 @@ export function processCombatTick( const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed; const isSpellAoe = !!spellDef.isAoe; - castProgress = (castProgress || 0) + progressPerTick; + // Per-weapon cast progress for primary spell (spec §3.1, D-01 fix) + // Fall back to legacy castProgress for backward compatibility + weaponCastProgress['primary'] = ((weaponCastProgress['primary'] ?? state.castProgress) || 0) + progressPerTick; // Process complete casts for active spell let safetyCounter = 0; const MAX_CASTS_PER_TICK = 100; - while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) { + while (weaponCastProgress['primary'] >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements) && safetyCounter < MAX_CASTS_PER_TICK) { const afterCost = deductSpellCost(spellDef.cost, rawMana, elements); rawMana = afterCost.rawMana; elements = afterCost.elements; @@ -163,11 +167,11 @@ export function processCombatTick( } // Apply damage per-enemy (spec §3.2) - const roomResult = applyDamageToRoom(get, set, finalDamage, isSpellAoe); + const roomResult = applyDamageToRoom(get, set, finalDamage, isSpellAoe, spellDef.aoeTargets); floorHP = roomResult.floorHP; floorMaxHP = roomResult.floorMaxHP; currentRoom = get().currentRoom; - castProgress -= 1; + weaponCastProgress['primary'] -= 1; safetyCounter++; applyOnHitEffect(get, set, spellId, logMessages); @@ -182,7 +186,7 @@ export function processCombatTick( floorMaxHP = newState.floorMaxHP; floorHP = newState.floorHP; currentRoom = newState.currentRoom; - castProgress = 0; + weaponCastProgress['primary'] = 0; if (guardian) { logMessages.push(`⚔️ ${guardian.name} defeated!`); } else if (currentFloor % 5 === 0) { @@ -224,7 +228,7 @@ export function processCombatTick( if (!Number.isFinite(eFinalDamage)) break; // Apply damage per-enemy (spec §3.2) - const eRoomResult = applyDamageToRoom(get, set, eFinalDamage, isESpellAoe); + const eRoomResult = applyDamageToRoom(get, set, eFinalDamage, isESpellAoe, eSpellDef.aoeTargets); floorHP = eRoomResult.floorHP; floorMaxHP = eRoomResult.floorMaxHP; currentRoom = get().currentRoom; @@ -360,7 +364,8 @@ export function processCombatTick( floorHP, floorMaxHP, maxFloorReached: newMaxFloorReached, - castProgress, + castProgress: weaponCastProgress['primary'] ?? state.castProgress, + weaponCastProgress, equipmentSpellStates: updatedEquipmentSpellStates, activeGolems, meleeSwordProgress: updatedMeleeSwordProgress, diff --git a/src/lib/game/stores/combat-damage.ts b/src/lib/game/stores/combat-damage.ts index 473e90f..a51244d 100644 --- a/src/lib/game/stores/combat-damage.ts +++ b/src/lib/game/stores/combat-damage.ts @@ -23,11 +23,12 @@ export function lowestHPEnemy(enemies: EnemyState[]): EnemyState | null { * Apply damage to enemies in the current room. * * Spec §3.2: - * - AoE spells: each enemy takes full damage + * - AoE spells: damage is distributed across up to aoeTargets enemies * - Single-target: target the enemy with lowest HP (focus-fire) * - Recalculates floorHP as sum of all enemy HP * - Triggers onRoomCleared when all enemies reach 0 HP * + * @param aoeTargets — max number of enemies hit by AoE (spec §3.2, D-38 fix) * @returns { floorHP, floorMaxHP, roomCleared } */ export function applyDamageToRoom( @@ -35,6 +36,7 @@ export function applyDamageToRoom( set: (state: Partial) => void, dmg: number, isAoe: boolean, + aoeTargets?: number, ): { floorHP: number; floorMaxHP: number; roomCleared: boolean } { const state = get(); const room = state.currentRoom; @@ -48,11 +50,23 @@ export function applyDamageToRoom( // For single-target, find the lowest HP enemy once (focus-fire) const singleTarget = isAoe ? null : lowestHPEnemy(room.enemies); + // For AoE, select up to aoeTargets living enemies (focus-fire: lowest HP first) + let aoeTargetSet: Set | null = null; + if (isAoe && aoeTargets && aoeTargets > 0) { + const livingEnemies = room.enemies + .filter(e => e.hp > 0) + .sort((a, b) => a.hp - b.hp); + const targets = livingEnemies.slice(0, aoeTargets); + aoeTargetSet = new Set(targets.map(e => e.id)); + } + const updatedEnemies = room.enemies.map((enemy) => { if (enemy.hp <= 0) return enemy; if (isAoe) { - // AoE: each enemy takes full damage - return { ...enemy, hp: Math.max(0, enemy.hp - dmg) }; + // AoE: distribute damage across up to aoeTargets enemies (spec §3.2, D-38 fix) + if (aoeTargetSet && !aoeTargetSet.has(enemy.id)) return enemy; + const dmgPerEnemy = aoeTargetSet ? dmg / aoeTargetSet.size : dmg; + return { ...enemy, hp: Math.max(0, enemy.hp - dmgPerEnemy) }; } // Single-target: only damage the lowest HP enemy if (singleTarget && enemy.id === singleTarget.id) { diff --git a/src/lib/game/stores/combat-descent-actions.ts b/src/lib/game/stores/combat-descent-actions.ts index fb5e5c7..2fc0061 100644 --- a/src/lib/game/stores/combat-descent-actions.ts +++ b/src/lib/game/stores/combat-descent-actions.ts @@ -72,6 +72,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void { floorHP: newFloorHP, floorMaxHP: newFloorHP, castProgress: 0, + weaponCastProgress: {}, }); get().addActivityLog('floor_transition', `Descended to Floor ${newFloor}`); } else { @@ -84,6 +85,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void { floorHP: newFloorHP, floorMaxHP: newFloorHP, castProgress: 0, + weaponCastProgress: {}, }); } @@ -110,6 +112,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void { floorHP: newFloorHP, floorMaxHP: newFloorHP, castProgress: 0, + weaponCastProgress: {}, }); get().addActivityLog('floor_transition', `Ascending to Floor ${newFloor}`); } else { @@ -122,6 +125,7 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void { floorHP: newFloorHP, floorMaxHP: newFloorHP, castProgress: 0, + weaponCastProgress: {}, }); } @@ -216,7 +220,7 @@ export function onEnterRoomDescend(get: GetFn, set: SetFn): void { if (didReset) { const newRoom = generateSpireFloorState(s.currentFloor, s.currentRoomIndex, s.roomsPerFloor, s.runId); - set({ currentRoom: newRoom, castProgress: 0 }); + set({ currentRoom: newRoom, castProgress: 0, weaponCastProgress: {} }); get().addActivityLog('floor_transition', `Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1} has reset — enemies respawned`); @@ -263,6 +267,7 @@ export function createEnterSpireMode(get: GetFn, set: SetFn) { floorMaxHP: calcRoomHP(freshRoom), currentRoom: freshRoom, castProgress: 0, + weaponCastProgress: {}, climbDirection: 'up', isDescending: false, clearedFloors: {}, diff --git a/src/lib/game/stores/combat-state.types.ts b/src/lib/game/stores/combat-state.types.ts index 5019454..a6e564f 100644 --- a/src/lib/game/stores/combat-state.types.ts +++ b/src/lib/game/stores/combat-state.types.ts @@ -61,6 +61,10 @@ export interface CombatState { // Equipment spell states for multi-casting equipmentSpellStates: EquipmentSpellState[]; + // Per-weapon cast progress records (spec §3.1, D-01 fix) + // Key: 'primary' for active spell, or instanceId for equipment spells + weaponCastProgress: Record; + // Combat special effect tracking comboHitCount: number; floorHitCount: number; @@ -158,7 +162,7 @@ export interface CombatActions { maxMana: number, attackSpeedMult: number, onFloorCleared: (floor: number, wasGuardian: boolean) => void, - onDamageDealt: (damage: number) => { rawMana: number; elements: Record }, + onDamageDealt: (damage: number, skipSpecials?: boolean) => { rawMana: number; elements: Record }, signedPacts: number[], golemancyState: { activeGolems: RuntimeActiveGolem[] }, golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }, diff --git a/src/lib/game/stores/combatStore.ts b/src/lib/game/stores/combatStore.ts index f22dd07..6ade775 100644 --- a/src/lib/game/stores/combatStore.ts +++ b/src/lib/game/stores/combatStore.ts @@ -67,6 +67,9 @@ export const useCombatStore = create()( // Equipment spell states equipmentSpellStates: [], + // Per-weapon cast progress records (spec §3.1, D-01 fix) + weaponCastProgress: {}, + // Combat tracking comboHitCount: 0, floorHitCount: 0, @@ -178,6 +181,7 @@ export const useCombatStore = create()( floorMaxHP: getFloorMaxHP(newFloor), floorHP: getFloorMaxHP(newFloor), castProgress: 0, + weaponCastProgress: {}, }; }); }, @@ -290,7 +294,7 @@ export const useCombatStore = create()( maxMana: number, attackSpeedMult: number, onFloorCleared: (floor: number, wasGuardian: boolean) => void, - onDamageDealt: (damage: number) => { rawMana: number; elements: Record }, + onDamageDealt: (damage: number, skipSpecials?: boolean) => { rawMana: number; elements: Record }, signedPacts: number[], golemancyState: { activeGolems: RuntimeActiveGolem[] }, golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }, @@ -355,6 +359,7 @@ export const useCombatStore = create()( clearedFloors: state.clearedFloors, golemancy: state.golemancy, equipmentSpellStates: state.equipmentSpellStates, + weaponCastProgress: state.weaponCastProgress, comboHitCount: state.comboHitCount, floorHitCount: state.floorHitCount, activityLog: state.activityLog, diff --git a/src/lib/game/stores/dot-runtime.ts b/src/lib/game/stores/dot-runtime.ts index 414a2ce..a855155 100644 --- a/src/lib/game/stores/dot-runtime.ts +++ b/src/lib/game/stores/dot-runtime.ts @@ -86,8 +86,8 @@ export function processDoTPhase( } let enemyHp = enemy.hp; - let enemyArmor = enemy.effectiveArmor; const remainingEffects: ActiveEffect[] = []; + let totalCorrodeMagnitude = 0; for (const effect of enemy.activeEffects) { if (DOT_EFFECT_TYPES.has(effect.type)) { @@ -110,8 +110,8 @@ export function processDoTPhase( // Curse: amplifies incoming damage (tracked on enemy, applied next tick) // No immediate HP effect — curse multiplier is stored on the enemy } else if (effect.type === 'armor_corrode') { - // Armor corrode: reduce effective armor - enemyArmor = Math.max(0, enemyArmor - effect.magnitude); + // Armor corrode: accumulate magnitude for temporary reduction (spec §6.3, D-31 fix) + totalCorrodeMagnitude += effect.magnitude; } // freeze, slow, blind: soft CC — no HP effect, just tracked @@ -122,10 +122,13 @@ export function processDoTPhase( } } + // Compute effectiveArmor from base armor minus active corrode effects (temporary) + const effectiveArmor = Math.max(0, enemy.armor - totalCorrodeMagnitude); + updatedEnemies.push({ ...enemy, hp: enemyHp, - effectiveArmor: enemyArmor, + effectiveArmor, activeEffects: remainingEffects, }); } diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index 5936670..b1999f1 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -247,6 +247,8 @@ export const useGameStore = create()( // Combat if (ctx.combat.currentAction === 'climb') { const combatCbs = buildCombatCallbacks({ ctx, effects, maxMana, addLog, useCombatStore, usePrestigeStore }); + // Guardian regen once per tick (not per-damage-event, spec §5.3, D-25 fix) + combatCbs.applyGuardianRegen(); const roomEnemies = ctx.combat.currentRoom?.enemies ?? []; const primaryEnemy = roomEnemies[0] ?? null; const activeEnemy = combatCbs.applyMageBarrierRecharge(primaryEnemy) ?? primaryEnemy; @@ -272,7 +274,7 @@ export const useGameStore = create()( rawMana = cr.rawMana; elements = cr.elements; totalManaGathered += cr.totalManaGathered || 0; if (cr.logMessages) cr.logMessages.forEach(msg => addLog(msg)); - writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom }; + writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, weaponCastProgress: cr.weaponCastProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom } as Partial; } // Non-combat room tick (library, recovery, treasure, puzzle) diff --git a/src/lib/game/stores/golem-combat-actions.ts b/src/lib/game/stores/golem-combat-actions.ts index 9d1e78d..78fad37 100644 --- a/src/lib/game/stores/golem-combat-actions.ts +++ b/src/lib/game/stores/golem-combat-actions.ts @@ -244,7 +244,7 @@ export function processGolemManaRegen( export function processGolemAttacks( activeGolems: RuntimeActiveGolem[], golemDesigns: Record, - onDamageDealt: (damage: number) => { + onDamageDealt: (damage: number, skipSpecials?: boolean) => { rawMana: number; elements: Record; modifiedDamage?: number; @@ -295,7 +295,7 @@ export function processGolemAttacks( updatedGolem.currentMana -= spellManaCost; updatedGolem.spellCastIndex = (updatedGolem.spellCastIndex + 1) % design.selectedSpells.length; - const dmgResult = onDamageDealt(spellDmg); + const dmgResult = onDamageDealt(spellDmg, true); const finalDamage = dmgResult.modifiedDamage || spellDmg; if (Number.isFinite(finalDamage)) { @@ -326,7 +326,7 @@ export function processGolemAttacks( onApplyEnchantmentEffects(targetEnemy.id, enchantmentEffects); } - const dmgResult = onDamageDealt(dmg); + const dmgResult = onDamageDealt(dmg, true); const finalDamage = dmgResult.modifiedDamage || dmg; if (Number.isFinite(finalDamage)) { diff --git a/src/lib/game/stores/pipelines/combat-tick.ts b/src/lib/game/stores/pipelines/combat-tick.ts index 519ce01..ea450ac 100644 --- a/src/lib/game/stores/pipelines/combat-tick.ts +++ b/src/lib/game/stores/pipelines/combat-tick.ts @@ -89,7 +89,14 @@ export function applyEnemyDefenses( return 0; } - // 2. Barrier absorption (percentage, spec §5.2) — skipped if bypassBarrier + // 2a. Shield pool absorption (flat HP one-time pool, spec §5.1) — skipped if bypassBarrier + if (!bypassBarrier && enemy.shieldPool && enemy.shieldPool > 0) { + const absorb = Math.min(enemy.shieldPool, dmg); + enemy.shieldPool -= absorb; + dmg -= absorb; + } + + // 2b. Barrier absorption (percentage, spec §5.2) — skipped if bypassBarrier if (!bypassBarrier && enemy.barrier && enemy.barrier > 0) { dmg *= (1 - enemy.barrier); } @@ -135,6 +142,7 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) { /** Mage barrier recharge rate (spec §5.2): 5% per tick */ const MAGE_BARRIER_RECHARGE_RATE = 0.05; + const MAGE_BARRIER_MAX = 0.4; // maxBarrier from MODIFIER_CONFIG.mage /** * Apply mage barrier recharge (spec §5.2). @@ -160,45 +168,63 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) { defCtx: EnemyDefenseCtx, addLog: (msg: string) => void, ) => { - return (damage: number) => { + return (damage: number, skipSpecials?: boolean) => { const rawMana = rawManaRef(); const elements = elementsRef(); let dmg = damage; // Discipline specials (Executioner, Berserker) — before enemy defenses - if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) { - dmg *= 2; - } - if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) { - dmg *= 1.5; + // Skipped for golem attacks (spec §9.4, D-04 fix) + if (!skipSpecials) { + // Executioner: per-enemy HP check (spec §4.4, D-15 fix) + const executionerTarget = defCtx.enemy; + if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && executionerTarget && executionerTarget.hp < executionerTarget.maxHP * 0.25) { + dmg *= 2; + } + if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) { + dmg *= 1.5; + } } // Apply regular enemy defenses for ALL enemies (spec §5.2) dmg = defApply(dmg, defCtx.enemy, defCtx.roomType, addLog); - // Guardian-specific defensive pipeline (shield → barrier → health regen, spec §5.3) + // Mage barrier recharge (spec §5.2) — after damage, recharge mage barrier on the enemy + if (defCtx.enemy && defCtx.enemy.barrier && defCtx.enemy.barrier > 0 && defCtx.enemy.name.startsWith('Mage')) { + const rechargedBarrier = Math.min( + MAGE_BARRIER_MAX, + defCtx.enemy.barrier + MAGE_BARRIER_RECHARGE_RATE * HOURS_PER_TICK, + ); + // Update enemy barrier on the store (mutates via reference) + defCtx.enemy.barrier = rechargedBarrier; + } + + // Guardian-specific defensive pipeline (spec §5.3, §11) const guardian = getGuardianForFloor(ctx.combat.currentFloor); - if (guardian && (guardian.shield || guardian.barrier || guardian.healthRegen)) { + if (guardian) { let shield = ctx.combat.guardianShield; const shieldMax = ctx.combat.guardianShieldMax; let barrier = ctx.combat.guardianBarrier; const barrierMax = ctx.combat.guardianBarrierMax; - if (guardian.shieldRegen && shield < shieldMax) { - shield = Math.min(shieldMax, shield + guardian.shieldRegen * HOURS_PER_TICK); - } - if (guardian.barrierRegen && barrier < barrierMax) { - barrier = Math.min(barrierMax, barrier + guardian.barrierRegen * HOURS_PER_TICK); - } - + // Shield absorption (flat pool, per-hit) if (shield > 0 && dmg > 0) { const absorb = Math.min(shield, dmg); shield -= absorb; dmg -= absorb; } + + // Barrier reduction (percentage, per-hit) if (barrier > 0 && dmg > 0) { dmg *= (1 - barrier); } + + // Guardian armor reduction (spec §11, D-26 fix) + if (guardian.armor && guardian.armor > 0 && dmg > 0) { + dmg *= (1 - guardian.armor); + } + + // Health regen reduces net damage (per-hit, already scaled by HOURS_PER_TICK) if (guardian.healthRegen && guardian.healthRegen > 0) { const healAmount = guardian.healthRegenIsPercent ? Math.floor(ctx.combat.floorMaxHP * guardian.healthRegen / 100 * HOURS_PER_TICK) @@ -218,5 +244,40 @@ export function buildCombatCallbacks(params: BuildCombatCallbacksParams) { }; }; - return { onFloorCleared, makeOnDamageDealt, applyMageBarrierRecharge }; + // ─── Guardian Regen (once per tick, spec §5.3, D-25 fix) ───────────────── + + /** + * Apply guardian shield/barrier regen once per tick. + * Must be called exactly once per combat tick, not per-damage-event. + */ + const applyGuardianRegen = () => { + const guardian = getGuardianForFloor(ctx.combat.currentFloor); + if (!guardian) return; + + let shield = ctx.combat.guardianShield; + const shieldMax = ctx.combat.guardianShieldMax; + let barrier = ctx.combat.guardianBarrier; + const barrierMax = ctx.combat.guardianBarrierMax; + let changed = false; + + if (guardian.shieldRegen && shield < shieldMax) { + shield = Math.min(shieldMax, shield + guardian.shieldRegen * HOURS_PER_TICK); + changed = true; + } + if (guardian.barrierRegen && barrier < barrierMax) { + barrier = Math.min(barrierMax, barrier + guardian.barrierRegen * HOURS_PER_TICK); + changed = true; + } + + if (changed) { + useCombatStore.setState({ + guardianShield: shield, + guardianShieldMax: shieldMax, + guardianBarrier: barrier, + guardianBarrierMax: barrierMax, + }); + } + }; + + return { onFloorCleared, makeOnDamageDealt, applyMageBarrierRecharge, applyGuardianRegen }; } diff --git a/src/lib/game/types/game.ts b/src/lib/game/types/game.ts index 4ffa2f1..bbd06c1 100644 --- a/src/lib/game/types/game.ts +++ b/src/lib/game/types/game.ts @@ -64,7 +64,8 @@ export interface EnemyState { maxHP: number; armor: number; // Damage reduction (0-1) dodgeChance: number; // For speed rooms (0-1) - barrier?: number; // Shield that absorbs damage before HP (0-1 as percentage of max HP) + barrier?: number; // Percentage damage reduction (0-1), e.g. from mage modifier + shieldPool?: number; // Flat HP one-time shield pool (spec §5.1), e.g. from shield modifier element: string; activeEffects: ActiveEffect[]; // DoT/debuff effects currently on this enemy effectiveArmor: number; // Armor after corrode effects (0-1) diff --git a/src/lib/game/utils/combat-utils.ts b/src/lib/game/utils/combat-utils.ts index b746a34..c68c7f6 100644 --- a/src/lib/game/utils/combat-utils.ts +++ b/src/lib/game/utils/combat-utils.ts @@ -33,19 +33,20 @@ export interface DPSCalcParams { } // ─── Elemental Damage Bonus ────────────────────────────────────────────────── - -// Elemental damage bonus: +50% if spell element opposes floor element (super effective) +// +50% if spell element opposes floor element (super effective) // -25% if spell element matches its own opposite (weak) export function getElementalBonus(spellElem: string, floorElem: string): number { if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus if (spellElem === floorElem) return 1.25; // Same element: +25% damage - // Check for super effective first: spell is the opposite of floor - if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective: +50% damage + // Check for super effective: spellElem is in floorElem's opposites list + const floorOpposites = ELEMENT_OPPOSITES[floorElem]; + if (floorOpposites && floorOpposites.includes(spellElem)) return 1.5; // Super effective: +50% damage - // Check for weak: spell's opposite matches floor - if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak: -25% damage + // Check for weak: floorElem is in spellElem's opposites list + const spellOpposites = ELEMENT_OPPOSITES[spellElem]; + if (spellOpposites && spellOpposites.includes(floorElem)) return 0.75; // Weak: -25% damage return 1.0; // Neutral } diff --git a/src/lib/game/utils/enemy-generator.ts b/src/lib/game/utils/enemy-generator.ts index 2006050..33162a8 100644 --- a/src/lib/game/utils/enemy-generator.ts +++ b/src/lib/game/utils/enemy-generator.ts @@ -23,7 +23,7 @@ const MODIFIER_CONFIG = { barrierRechargeRate: 0.05, // Recharges 5% of max HP per tick }, shield: { - shieldAmount: 0.15, // 15% of max HP as one-time shield + shieldAmount: 0.15, // 15% of max HP as one-time flat shield pool (spec §5.1) }, armored: { armorPerFloor: 0.003, @@ -37,8 +37,8 @@ const MODIFIER_CONFIG = { armorPerFloor: 0.002, }, agile: { - baseDodge: 0.20, - dodgePerFloor: 0.004, + baseDodge: 0, + dodgePerFloor: 0.003, maxDodge: 0.55, }, }; @@ -126,10 +126,11 @@ export function generateEnemy(floor: number, modifiers?: EnemyModifier[]): Gener } if (activeModifiers.includes('shield')) { - barrier = Math.max(barrier, MODIFIER_CONFIG.shield.shieldAmount); name = `${name} (Shielded)`; } + const shieldPool = activeModifiers.includes('shield') ? hp * MODIFIER_CONFIG.shield.shieldAmount : 0; + return { id: 'enemy', name, @@ -138,7 +139,10 @@ export function generateEnemy(floor: number, modifiers?: EnemyModifier[]): Gener armor, dodgeChance, barrier, + shieldPool, element, + activeEffects: [], + effectiveArmor: armor, modifiers: activeModifiers, }; } @@ -172,6 +176,8 @@ export function generateSwarm(floor: number, modifiers?: EnemyModifier[]): Gener : 0, barrier: 0, element, + activeEffects: [], + effectiveArmor: armor, modifiers: activeModifiers, }); }