diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 660c9b4..4b37ac3 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-06-07T21:06:17.789Z +Generated: 2026-06-07T21:16:10.397Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 45b5358..850f935 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-07T21:06:15.829Z", + "generated": "2026-06-07T21:16:08.368Z", "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." }, @@ -203,6 +203,7 @@ ], "crafting-fabricator.ts": [ "data/fabricator-recipes.ts", + "effects/discipline-effects.ts", "stores/manaStore.ts", "types.ts" ], diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 6da1451..89397ab 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -385,7 +385,10 @@ Mana-Loop/ │ │ │ │ ├── gameLoopActions.ts │ │ │ │ ├── gameStore.ts │ │ │ │ ├── gameStore.types.ts +│ │ │ │ ├── golem-combat-actions.test.ts │ │ │ │ ├── golem-combat-actions.ts +│ │ │ │ ├── golem-combat-helpers.test.ts +│ │ │ │ ├── golem-combat-helpers.ts │ │ │ │ ├── golemancy-actions.ts │ │ │ │ ├── golemancy-combat.test.ts │ │ │ │ ├── index.ts diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts index d37dcc1..b034ed9 100644 --- a/src/lib/game/stores/combat-actions.ts +++ b/src/lib/game/stores/combat-actions.ts @@ -12,9 +12,9 @@ import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcM import { computeDisciplineEffects } from '../effects/discipline-effects'; import { processGolemMaintenance, - processGolemAttacks, processGolemManaRegen, } from './golem-combat-actions'; +import { processGolemAttacksFromStore } from './golem-combat-helpers'; import { applyDamageToRoom } from './combat-damage'; // ─── Result Type ─────────────────────────────────────────────────────────────── @@ -306,11 +306,14 @@ export function processCombatTick( // ─── Golem attacks (spec §11) ─────────────────────────────────────────── if (activeGolems.length > 0 && floorHP > 0) { - const golemResult = processGolemAttacks( + const golemResult = processGolemAttacksFromStore( activeGolems, golemDesigns, onDamageDealt, golemApplyDamageToRoom, + getFloorElement(currentFloor), + get, + set, ); rawMana = golemResult.rawMana; elements = golemResult.elements; diff --git a/src/lib/game/stores/golem-combat-actions.test.ts b/src/lib/game/stores/golem-combat-actions.test.ts new file mode 100644 index 0000000..e8d69cb --- /dev/null +++ b/src/lib/game/stores/golem-combat-actions.test.ts @@ -0,0 +1,347 @@ +// ─── Golem Combat Actions Regression Tests (Issue #313) ──────────────────────── +// Tests the 5 fixes applied to processGolemAttacks: +// 1. Spell damage uses actual SPELLS_DEF[spellId].dmg * frame.magicAffinity +// 2. Spell mana cost uses actual SPELLS_DEF[spellId].cost.amount +// 3. Elemental matchup applied to basic attacks +// 4. Enchantment effects applied to basic attacks +// 5. Armor pierce bypasses armor fraction instead of multiplying damage + +import { describe, it, expect } from 'vitest'; +import { + CORES, FRAMES, MIND_CIRCUITS, GOLEM_ENCHANTMENTS, +} from '@/lib/game/data/golems'; +import { SPELLS_DEF } from '@/lib/game/constants'; +import type { GolemDesign, SerializedDesign } from '@/lib/game/data/golems/types'; +import type { EnemyState, RuntimeActiveGolem } from '@/lib/game/types'; +import { processGolemAttacks } from './golem-combat-actions'; +import { computeBasicAttackDamage } from './golem-combat-helpers'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeDesign( + coreId: string, + frameId: string, + circuitId: string, + enchantIds: string[] = [], + selectedSpells: string[] = [], +): GolemDesign { + return { + id: `test_${coreId}_${frameId}_${circuitId}`, + name: `Test ${coreId} ${frameId}`, + core: CORES[coreId], + frame: FRAMES[frameId], + mindCircuit: MIND_CIRCUITS[circuitId], + enchantments: enchantIds.map(id => GOLEM_ENCHANTMENTS[id]).filter(Boolean), + selectedManaTypes: [], + selectedSpells, + }; +} + +function makeSerialized(design: GolemDesign): SerializedDesign { + return { + id: design.id, + name: design.name, + coreId: design.core.id, + frameId: design.frame.id, + mindCircuitId: design.mindCircuit.id, + enchantmentIds: design.enchantments.map(e => e.id), + selectedManaTypes: design.selectedManaTypes, + selectedSpells: design.selectedSpells, + }; +} + +function makeActiveGolem(design: GolemDesign, currentMana?: number, attackProgress = 0): RuntimeActiveGolem { + return { + designId: design.id, + summonedFloor: 1, + attackProgress, + roomsRemaining: 3, + currentMana: currentMana ?? design.core.manaCapacity, + spellCastIndex: 0, + }; +} + +function makeEnemy(overrides: Partial = {}): EnemyState { + return { + id: 'enemy-1', + name: 'Test Enemy', + hp: 100, + maxHP: 100, + armor: 0.2, + dodgeChance: 0, + element: 'fire', + activeEffects: [], + effectiveArmor: 0.2, + ...overrides, + }; +} + +// Helper: run processGolemAttacks with attackProgress pre-set to trigger attacks +function runGolemAttacks( + golem: RuntimeActiveGolem, + serialized: SerializedDesign, + design: GolemDesign, + enemy: EnemyState, + enemyElement: string, + onDamage?: (dmg: number) => { rawMana: number; elements: Record }, +) { + let capturedDamage = 0; + const result = processGolemAttacks( + [golem], + { [design.id]: serialized }, + onDamage ?? ((dmg) => { capturedDamage = dmg; return { rawMana: 0, elements: {} }; }), + () => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }), + enemyElement, + () => enemy, + () => {}, + ); + return { result, capturedDamage }; +} + +// ─── Fix #1 & #2: Spell damage and mana cost ───────────────────────────────── + +describe('processGolemAttacks - spell damage and mana cost (fixes #1, #2)', () => { + it('uses actual spell damage (SPELLS_DEF.dmg * magicAffinity) not hardcoded 20', () => { + const design = makeDesign('basic', 'earth', 'intermediate', [], ['fireball']); + const serialized = makeSerialized(design); + const spellDef = SPELLS_DEF['fireball']; + const frame = FRAMES['earth']; + const golem = makeActiveGolem(design, 100, 1.0); + const enemy = makeEnemy(); + + const expectedSpellDmg = spellDef.dmg * frame.magicAffinity; // 15 * 0.3 = 4.5 + + const { capturedDamage } = runGolemAttacks(golem, serialized, design, enemy, 'fire'); + + expect(capturedDamage).toBe(expectedSpellDmg); + expect(capturedDamage).not.toBe(20 * frame.magicAffinity); // Old buggy value would be 6 + }); + + it('uses actual spell mana cost not hardcoded 10', () => { + const design = makeDesign('basic', 'earth', 'intermediate', [], ['fireball']); + const serialized = makeSerialized(design); + const spellDef = SPELLS_DEF['fireball']; + const golem = makeActiveGolem(design, spellDef.cost.amount, 1.0); + const enemy = makeEnemy(); + + let spellCastCount = 0; + processGolemAttacks( + [golem], + { [design.id]: serialized }, + () => { spellCastCount++; return { rawMana: 0, elements: {} }; }, + () => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }), + 'fire', + () => enemy, + () => {}, + ); + + expect(spellCastCount).toBeGreaterThan(0); + }); + + it('falls back to basic attack when golem mana is below actual spell cost', () => { + const design = makeDesign('basic', 'earth', 'intermediate', [], ['fireball']); + const serialized = makeSerialized(design); + const spellDef = SPELLS_DEF['fireball']; + // Give golem less mana than the spell costs (fireball costs 2) + const golem = makeActiveGolem(design, spellDef.cost.amount - 1, 1.0); + // Use 0 armor enemy so basic attack damage is full baseDamage (6) + const enemy = makeEnemy({ armor: 0 }); + + let damageCount = 0; + processGolemAttacks( + [golem], + { [design.id]: serialized }, + () => { damageCount++; return { rawMana: 0, elements: {} }; }, + () => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }), + 'fire', + () => enemy, + () => {}, + ); + + // Should fall back to basic attack since mana is insufficient + expect(damageCount).toBeGreaterThan(0); + }); +}); + +// ─── Fix #3: Elemental matchup ─────────────────────────────────────────────── + +describe('processGolemAttacks - elemental matchup (fix #3)', () => { + it('applies super effective elemental bonus (1.5x) to basic attacks', () => { + // Water is super effective vs fire (ELEMENT_OPPOSITES['fire'] = 'water') + // Use a frame with element that is super effective vs the enemy element + // Fire frame (Steel has element 'metal', Crystal has 'crystal') + // Let's use computeBasicAttackDamage directly with water element vs fire enemy + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0, element: 'water' }, + 0, 0, 'fire', + ); + expect(dmg).toBe(15); // water vs fire = 1.5x + }); + + it('applies weak elemental bonus (0.75x) when frame element is weak', () => { + // Lightning is weak to earth: ELEMENT_OPPOSITES['lightning'] = 'earth' + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0, element: 'lightning' }, + 0, 0, 'earth', + ); + expect(dmg).toBe(7.5); // 10 * 0.75 + }); + + it('applies same-element bonus (1.25x) when frame element matches enemy', () => { + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0, element: 'fire' }, + 0, 0, 'fire', + ); + expect(dmg).toBe(12.5); // 10 * 1.25 + }); + + it('applies neutral bonus (1.0x) for unrelated elements', () => { + // Death element has no specific matchup with fire = neutral + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0, element: 'death' }, + 0, 0, 'fire', + ); + expect(dmg).toBe(10); // 1.0x neutral + }); + + it('applies elemental bonus through processGolemAttacks (integration)', () => { + // Crystal frame has element 'crystal'. Test with an enemy element where crystal is neutral. + // Crystal vs fire = neutral (1.0x) + const design = makeDesign('basic', 'crystal', 'simple'); + const serialized = makeSerialized(design); + const golem = makeActiveGolem(design, undefined, 1.0); + const enemy = makeEnemy({ element: 'fire', armor: 0 }); // No armor for clean test + + const frame = FRAMES['crystal']; + const expectedDmg = frame.baseDamage; // 1.0x neutral, no armor + + const { capturedDamage } = runGolemAttacks(golem, serialized, design, enemy, 'fire'); + expect(capturedDamage).toBe(expectedDmg); + }); +}); + +// ─── Fix #4: Enchantment effects ───────────────────────────────────────────── + +describe('processGolemAttacks - enchantment effects (fix #4)', () => { + it('applies enchantment effects to target enemy on basic attack', () => { + const design = makeDesign('basic', 'earth', 'simple', ['sword_fire']); + const serialized = makeSerialized(design); + const golem = makeActiveGolem(design, undefined, 1.0); + const enemy = makeEnemy(); + + const appliedEffects: { enemyId: string; effects: { type: string; magnitude: number }[] }[] = []; + processGolemAttacks( + [golem], + { [design.id]: serialized }, + () => ({ rawMana: 0, elements: {} }), + () => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }), + 'fire', + () => enemy, + (enemyId, effects) => { appliedEffects.push({ enemyId, effects }); }, + ); + + expect(appliedEffects.length).toBeGreaterThan(0); + expect(appliedEffects[0].effects.some(e => e.type === 'burn')).toBe(true); + }); + + it('applies multiple enchantment effects when multiple enchantments equipped', () => { + const design = makeDesign('basic', 'earth', 'simple', ['sword_fire', 'sword_frost', 'sword_lightning']); + const serialized = makeSerialized(design); + const golem = makeActiveGolem(design, undefined, 1.0); + const enemy = makeEnemy(); + + const appliedEffects: { type: string; magnitude: number }[][] = []; + processGolemAttacks( + [golem], + { [design.id]: serialized }, + () => ({ rawMana: 0, elements: {} }), + () => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }), + 'fire', + () => enemy, + (_, effects) => { appliedEffects.push(effects); }, + ); + + const allEffectTypes = appliedEffects.flat().map(e => e.type); + expect(allEffectTypes).toContain('burn'); + expect(allEffectTypes).toContain('slow'); + expect(allEffectTypes).toContain('shock'); + }); + + it('does not apply enchantment effects when no enchantments equipped', () => { + const design = makeDesign('basic', 'earth', 'simple', []); + const serialized = makeSerialized(design); + const golem = makeActiveGolem(design, undefined, 1.0); + const enemy = makeEnemy(); + + let effectsCalled = false; + processGolemAttacks( + [golem], + { [design.id]: serialized }, + () => ({ rawMana: 0, elements: {} }), + () => ({ floorHP: 50, floorMaxHP: 100, roomCleared: false }), + 'fire', + () => enemy, + () => { effectsCalled = true; }, + ); + + expect(effectsCalled).toBe(false); + }); +}); + +// ─── Fix #5: Armor pierce ──────────────────────────────────────────────────── + +describe('processGolemAttacks - armor pierce (fix #5)', () => { + it('does not multiply damage by (1 + armorPierce)', () => { + // Steel frame: baseDamage=18, armorPierce=0.5 + // Old buggy formula: 18 * (1 + 0.5) = 27 + // Correct: 18 with 50% armor bypass against 40% armor + const design = makeDesign('basic', 'steel', 'simple'); + const serialized = makeSerialized(design); + const golem = makeActiveGolem(design, undefined, 1.0); + const enemy = makeEnemy({ armor: 0.4 }); + + const frame = FRAMES['steel']; + // With 50% armor pierce: effectiveArmor = 0.4 * (1 - 0.5) = 0.2 + // dmg = 18 * (1 - 0.2) = 14.4 + const expectedDmg = frame.baseDamage * (1 - 0.4 * (1 - frame.armorPierce)); + + const { capturedDamage } = runGolemAttacks(golem, serialized, design, enemy, 'fire'); + + // Should NOT be 18 * 1.5 = 27 (old buggy formula) + expect(capturedDamage).not.toBe(frame.baseDamage * (1 + frame.armorPierce)); + // Should be the correct armor-bypassed value + expect(capturedDamage).toBe(expectedDmg); + }); + + it('fully bypasses armor when armorPierce is 1.0', () => { + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 1.0, element: undefined }, + 0, 0.8, 'fire', + ); + expect(dmg).toBe(10); // Full damage, armor fully bypassed + }); + + it('applies no armor bypass when armorPierce is 0', () => { + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0, element: undefined }, + 0, 0.5, 'fire', + ); + // 50% armor, no pierce: 10 * (1 - 0.5) = 5 + expect(dmg).toBe(5); + }); + + it('stacks enchantment armorPierce with frame armorPierce', () => { + const framePierce = 0.5; + const enchantPierce = 0.15; + const totalPierce = Math.min(1, framePierce + enchantPierce); + + const dmg = computeBasicAttackDamage( + { baseDamage: 20, armorPierce: framePierce, element: undefined }, + enchantPierce, 0.4, 'fire', + ); + const expected = 20 * (1 - 0.4 * (1 - totalPierce)); + expect(dmg).toBe(expected); + }); +}); + + diff --git a/src/lib/game/stores/golem-combat-actions.ts b/src/lib/game/stores/golem-combat-actions.ts index 1737b18..a6a6a11 100644 --- a/src/lib/game/stores/golem-combat-actions.ts +++ b/src/lib/game/stores/golem-combat-actions.ts @@ -3,14 +3,18 @@ // All external data is passed in as parameters (no cross-store getState() calls). // Implements spec §§10-14: summoning, maintenance, combat, mana, duration. -import { HOURS_PER_TICK } from '../constants'; +import { HOURS_PER_TICK, SPELLS_DEF } from '../constants'; import { CORES, FRAMES, MIND_CIRCUITS } from '../data/golems'; import { computeGolemStats, getGolemSlots } from '../data/golems/utils'; +import { + resolveEnchantmentEffects, + computeBasicAttackDamage, +} from './golem-combat-helpers'; +import type { GolemEnchantmentEffect } from './golem-combat-helpers'; import type { RuntimeActiveGolem, GolemLoadoutEntry, EnemyState, - ActiveEffect, } from '../types'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -60,22 +64,17 @@ export function summonGolemsOnRoomEntry( const newActiveGolems = [...existingActiveGolems]; const logMessages: string[] = []; - const activeCount = newActiveGolems.length; const baseSlots = getGolemSlots(fabricatorLevel); const totalSlots = Math.min(7, baseSlots + disciplineSlotsBonus); for (const entry of loadout) { if (!entry.enabled) continue; - - // Check slot availability (max 7 total per AC-1) if (newActiveGolems.length >= totalSlots) { logMessages.push('No golem slots available'); break; } const design = entry.design as SerializedDesign; - - // Resolve components const core = CORES[design.coreId]; const frame = FRAMES[design.frameId]; const circuit = MIND_CIRCUITS[design.mindCircuitId]; @@ -84,23 +83,20 @@ export function summonGolemsOnRoomEntry( continue; } - // Skip if already active const alreadyActive = newActiveGolems.some((ag) => ag.designId === entry.designId); if (alreadyActive) continue; - // Build component-based design for cost calculation const stats = computeGolemStats({ id: design.id, name: design.name, core: { ...core, manaTypes: design.selectedManaTypes.length > 0 ? design.selectedManaTypes : core.manaTypes }, frame, mindCircuit: circuit, - enchantments: [], // Simplified — enchantments resolved by ID in full implementation + enchantments: [], selectedManaTypes: design.selectedManaTypes, selectedSpells: design.selectedSpells, }); - // Check affordability let canAfford = true; for (const cost of stats.totalSummonCost) { if (cost.type === 'raw') { @@ -116,7 +112,6 @@ export function summonGolemsOnRoomEntry( continue; } - // Deduct summon cost for (const cost of stats.totalSummonCost) { if (cost.type === 'raw') { newRawMana -= cost.amount; @@ -140,12 +135,7 @@ export function summonGolemsOnRoomEntry( logMessages.push(`${entry.design.name} summoned`); } - return { - rawMana: newRawMana, - elements: newElements, - activeGolems: newActiveGolems, - logMessages, - }; + return { rawMana: newRawMana, elements: newElements, activeGolems: newActiveGolems, logMessages }; } // ─── Maintenance Upkeep (spec §13) ─────────────────────────────────────────── @@ -174,25 +164,17 @@ export function processGolemMaintenance( for (const golem of activeGolems) { const design = golemDesigns[golem.designId]; if (!design) continue; - const core = CORES[design.coreId]; if (!core) continue; - // Upkeep per tick = (manaRegen × 2) × HOURS_PER_TICK const upkeepPerTick = core.manaRegen * 2 * HOURS_PER_TICK; const upkeepElement = core.primaryManaType; - const elem = upkeepElement ? newElements[upkeepElement] : null; if (upkeepElement && elem && elem.unlocked && elem.current >= upkeepPerTick) { - // Deduct from element mana - newElements[upkeepElement] = { - ...elem, - current: elem.current - upkeepPerTick, - }; + newElements[upkeepElement] = { ...elem, current: elem.current - upkeepPerTick }; maintainedGolems.push(golem); } else if (!upkeepElement && newRawMana >= upkeepPerTick) { - // Deduct from raw mana newRawMana -= upkeepPerTick; maintainedGolems.push(golem); } else if (upkeepElement && (!elem || !elem.unlocked || elem.current < upkeepPerTick)) { @@ -202,12 +184,7 @@ export function processGolemMaintenance( } } - return { - rawMana: newRawMana, - elements: newElements, - maintainedGolems, - logMessages, - }; + return { rawMana: newRawMana, elements: newElements, maintainedGolems, logMessages }; } // ─── Golem Mana Regen (spec §12) ──────────────────────────────────────────── @@ -222,15 +199,10 @@ export function processGolemManaRegen( return activeGolems.map((golem) => { const design = golemDesigns[golem.designId]; if (!design) return golem; - const core = CORES[design.coreId]; if (!core) return golem; - const manaGain = core.manaRegen * HOURS_PER_TICK; - return { - ...golem, - currentMana: Math.min(core.manaCapacity, golem.currentMana + manaGain), - }; + return { ...golem, currentMana: Math.min(core.manaCapacity, golem.currentMana + manaGain) }; }); } @@ -240,6 +212,13 @@ export function processGolemManaRegen( * Process golem attacks for one combat tick. * Each golem accumulates attackProgress and fires when >= 1. * Supports spell casting via Mind Circuit behavior. + * + * Fixes applied (issue #313): + * 1. Spell damage: uses actual SPELLS_DEF[spellId].dmg * frame.magicAffinity + * 2. Spell mana cost: uses actual SPELLS_DEF[spellId].cost.amount + * 3. Elemental matchup: applies getElementalBonus to basic attacks + * 4. Enchantment effects: applies golem enchantment effects on basic attacks + * 5. Armor pierce: bypasses enemy armor fraction instead of multiplying damage */ export function processGolemAttacks( activeGolems: RuntimeActiveGolem[], @@ -250,6 +229,9 @@ export function processGolemAttacks( modifiedDamage?: number; }, applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }, + enemyElement: string, + getTargetEnemy: () => EnemyState | null, + onApplyEnchantmentEffects: (enemyId: string, effects: GolemEnchantmentEffect[]) => void, ): GolemCombatResult { let rawMana = 0; let elements: Record = {}; @@ -257,7 +239,6 @@ export function processGolemAttacks( let floorMaxHP = 0; const logMessages: string[] = []; let totalDamageDealt = 0; - const updatedGolems: RuntimeActiveGolem[] = []; for (const golem of activeGolems) { @@ -269,6 +250,9 @@ export function processGolemAttacks( const circuit = MIND_CIRCUITS[design.mindCircuitId]; if (!core || !frame || !circuit) continue; + const enchantmentEffects = resolveEnchantmentEffects(design.enchantmentIds); + const bonusArmorPierce = design.enchantmentIds.includes('sword_metal') ? 0.15 : 0; + let attackProgress = golem.attackProgress + HOURS_PER_TICK * frame.attackSpeed; const updatedGolem = { ...golem }; let safetyCounter = 0; @@ -279,34 +263,47 @@ export function processGolemAttacks( if (circuit.spellSlots > 0 && design.selectedSpells.length > 0) { const spellIdx = updatedGolem.spellCastIndex % design.selectedSpells.length; const spellId = design.selectedSpells[spellIdx]; + const spellDef = spellId ? SPELLS_DEF[spellId] : null; - // Spell casting simplified — full implementation needs spell cost/effect lookup - if (spellId && updatedGolem.currentMana >= 10) { - // Cast spell: damage scaled by magic affinity - const spellDmg = 20 * frame.magicAffinity; // Placeholder base spell damage - updatedGolem.currentMana -= 10; - updatedGolem.spellCastIndex = (updatedGolem.spellCastIndex + 1) % design.selectedSpells.length; + if (spellDef) { + const spellManaCost = spellDef.cost.amount; + if (updatedGolem.currentMana >= spellManaCost) { + // FIX #1: Use actual spell damage * magic affinity + const spellDmg = spellDef.dmg * frame.magicAffinity; + // FIX #2: Deduct actual spell mana cost + updatedGolem.currentMana -= spellManaCost; + updatedGolem.spellCastIndex = (updatedGolem.spellCastIndex + 1) % design.selectedSpells.length; - const dmgResult = onDamageDealt(spellDmg); - const finalDamage = dmgResult.modifiedDamage || spellDmg; + const dmgResult = onDamageDealt(spellDmg); + const finalDamage = dmgResult.modifiedDamage || spellDmg; - if (Number.isFinite(finalDamage)) { - const roomResult = applyDamageToRoom(finalDamage); - floorHP = roomResult.floorHP; - floorMaxHP = roomResult.floorMaxHP; - totalDamageDealt += Math.max(0, finalDamage); - rawMana = dmgResult.rawMana; - elements = dmgResult.elements; + if (Number.isFinite(finalDamage)) { + const roomResult = applyDamageToRoom(finalDamage); + floorHP = roomResult.floorHP; + floorMaxHP = roomResult.floorMaxHP; + totalDamageDealt += Math.max(0, finalDamage); + rawMana = dmgResult.rawMana; + elements = dmgResult.elements; + } + + attackProgress -= 1; + safetyCounter++; + continue; } - - attackProgress -= 1; - safetyCounter++; - continue; } } // Basic attack - let dmg = frame.baseDamage * (1 + frame.armorPierce); + // FIX #3, #4, #5: elemental matchup, enchantment effects, proper armor pierce + const targetEnemy = getTargetEnemy(); + const enemyArmor = targetEnemy?.armor ?? 0; + + const dmg = computeBasicAttackDamage(frame, bonusArmorPierce, enemyArmor, enemyElement); + + // Apply enchantment effects to target enemy + if (enchantmentEffects.length > 0 && targetEnemy) { + onApplyEnchantmentEffects(targetEnemy.id, enchantmentEffects); + } const dmgResult = onDamageDealt(dmg); const finalDamage = dmgResult.modifiedDamage || dmg; @@ -328,13 +325,7 @@ export function processGolemAttacks( updatedGolems.push(updatedGolem); } - return { - rawMana, - elements, - activeGolems: updatedGolems, - logMessages, - totalDamageDealt, - }; + return { rawMana, elements, activeGolems: updatedGolems, logMessages, totalDamageDealt }; } // ─── Room Duration Countdown (spec §14) ───────────────────────────────────── @@ -358,12 +349,10 @@ export function countdownGolemRoomDuration( for (const golem of activeGolems) { const design = golemDesigns[golem.designId]; if (!design) continue; - const core = CORES[design.coreId]; if (!core) continue; const newRoomsRemaining = golem.roomsRemaining - 1; - if (newRoomsRemaining <= 0) { dismissedNames.push(design.name); logMessages.push(`${design.name} has faded after ${core.maxRoomDuration} rooms`); diff --git a/src/lib/game/stores/golem-combat-helpers.test.ts b/src/lib/game/stores/golem-combat-helpers.test.ts new file mode 100644 index 0000000..a3f484e --- /dev/null +++ b/src/lib/game/stores/golem-combat-helpers.test.ts @@ -0,0 +1,142 @@ +// ─── Golem Combat Helpers Unit Tests (Issue #313) ────────────────────────────── +// Unit tests for computeBasicAttackDamage and resolveEnchantmentEffects. + +import { describe, it, expect } from 'vitest'; +import { computeBasicAttackDamage, resolveEnchantmentEffects } from './golem-combat-helpers'; + +// ─── computeBasicAttackDamage ──────────────────────────────────────────────── + +describe('computeBasicAttackDamage', () => { + it('returns baseDamage with no armor and no elemental effect', () => { + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0, element: undefined }, + 0, 0, 'fire', + ); + expect(dmg).toBe(10); + }); + + it('applies elemental bonus for super effective matchup', () => { + // Water vs Fire = 1.5x (water is opposite of fire) + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0, element: 'water' }, + 0, 0, 'fire', + ); + expect(dmg).toBe(15); + }); + + it('applies elemental penalty for weak matchup', () => { + // Lightning vs Earth = 0.75x (lightning is weak to earth) + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0, element: 'lightning' }, + 0, 0, 'earth', + ); + expect(dmg).toBe(7.5); + }); + + it('applies same-element bonus', () => { + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0, element: 'fire' }, + 0, 0, 'fire', + ); + expect(dmg).toBe(12.5); + }); + + it('never returns negative damage', () => { + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0, element: undefined }, + 0, 0.99, 'fire', + ); + expect(dmg).toBeGreaterThanOrEqual(0); + }); + + it('combines elemental bonus and armor pierce', () => { + // Water vs Fire = 1.5x, then 50% armor with 25% pierce + // effectiveArmor = 0.5 * (1 - 0.25) = 0.375 + // dmg = 10 * 1.5 * (1 - 0.375) = 9.375 + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0.25, element: 'water' }, + 0, 0.5, 'fire', + ); + expect(dmg).toBeCloseTo(9.375, 5); + }); + + it('fully bypasses armor when armorPierce is 1.0', () => { + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 1.0, element: undefined }, + 0, 0.8, 'fire', + ); + expect(dmg).toBe(10); + }); + + it('applies no armor bypass when armorPierce is 0', () => { + const dmg = computeBasicAttackDamage( + { baseDamage: 10, armorPierce: 0, element: undefined }, + 0, 0.5, 'fire', + ); + expect(dmg).toBe(5); + }); + + it('stacks enchantment armorPierce with frame armorPierce', () => { + const totalPierce = Math.min(1, 0.5 + 0.15); + const dmg = computeBasicAttackDamage( + { baseDamage: 20, armorPierce: 0.5, element: undefined }, + 0.15, 0.4, 'fire', + ); + expect(dmg).toBe(20 * (1 - 0.4 * (1 - totalPierce))); + }); +}); + +// ─── resolveEnchantmentEffects ──────────────────────────────────────────────── + +describe('resolveEnchantmentEffects', () => { + it('resolves sword_fire to burn effect', () => { + const effects = resolveEnchantmentEffects(['sword_fire']); + expect(effects).toHaveLength(1); + expect(effects[0].type).toBe('burn'); + expect(effects[0].magnitude).toBe(3); + }); + + it('resolves sword_frost to slow effect', () => { + const effects = resolveEnchantmentEffects(['sword_frost']); + expect(effects).toHaveLength(1); + expect(effects[0].type).toBe('slow'); + }); + + it('resolves sword_metal to armorPierce effect', () => { + const effects = resolveEnchantmentEffects(['sword_metal']); + expect(effects).toHaveLength(1); + expect(effects[0].type).toBe('armorPierce'); + expect(effects[0].magnitude).toBe(0.15); + }); + + it('resolves sword_lightning to shock effect', () => { + const effects = resolveEnchantmentEffects(['sword_lightning']); + expect(effects).toHaveLength(1); + expect(effects[0].type).toBe('shock'); + }); + + it('resolves sword_shadow to weaken effect', () => { + const effects = resolveEnchantmentEffects(['sword_shadow']); + expect(effects).toHaveLength(1); + expect(effects[0].type).toBe('weaken'); + }); + + it('returns empty array for unknown enchantment IDs', () => { + const effects = resolveEnchantmentEffects(['nonexistent_enchant']); + expect(effects).toHaveLength(0); + }); + + it('resolves multiple enchantments', () => { + const effects = resolveEnchantmentEffects(['sword_fire', 'sword_lightning', 'sword_shadow']); + expect(effects).toHaveLength(3); + const types = effects.map(e => e.type); + expect(types).toContain('burn'); + expect(types).toContain('shock'); + expect(types).toContain('weaken'); + }); + + it('handles empty array', () => { + const effects = resolveEnchantmentEffects([]); + expect(effects).toHaveLength(0); + }); +}); diff --git a/src/lib/game/stores/golem-combat-helpers.ts b/src/lib/game/stores/golem-combat-helpers.ts new file mode 100644 index 0000000..57d0588 --- /dev/null +++ b/src/lib/game/stores/golem-combat-helpers.ts @@ -0,0 +1,133 @@ +// ─── Golem Combat Helpers ───────────────────────────────────────────────────── +// Shared helpers for golem combat: enchantment resolution, basic attack damage, +// and store-wrapper for processGolemAttacks. +// Extracted from golem-combat-actions.ts to stay under the 400-line file limit. + +import { GOLEM_ENCHANTMENTS } from '../data/golems'; +import { getElementalBonus } from '../utils'; +import type { CombatStore, CombatState } from './combat-state.types'; +import type { + ActiveEffect, + EnemyState, + RuntimeActiveGolem, +} from '../types'; + +// ─── Enchantment Effect Types ──────────────────────────────────────────────── + +export interface GolemEnchantmentEffect { + type: 'burn' | 'slow' | 'shock' | 'weaken' | 'armorPierce' | 'criticalChance' | 'soak' | 'shieldBreak'; + magnitude: number; +} + +// ─── Enchantment Resolution ────────────────────────────────────────────────── + +/** Resolve enchantment effects from a list of enchantment IDs. */ +export function resolveEnchantmentEffects(enchantmentIds: string[]): GolemEnchantmentEffect[] { + const effects: GolemEnchantmentEffect[] = []; + for (const id of enchantmentIds) { + const ench = GOLEM_ENCHANTMENTS[id]; + if (!ench) continue; + switch (ench.effect) { + case 'burn': effects.push({ type: 'burn', magnitude: 3 }); break; + case 'slow': effects.push({ type: 'slow', magnitude: 0.3 }); break; + case 'shock': effects.push({ type: 'shock', magnitude: 0.25 }); break; + case 'weaken': effects.push({ type: 'weaken', magnitude: 0.2 }); break; + case 'armorPierce': effects.push({ type: 'armorPierce', magnitude: 0.15 }); break; + case 'criticalChance': effects.push({ type: 'criticalChance', magnitude: 0.1 }); break; + case 'soak': effects.push({ type: 'soak', magnitude: 0.3 }); break; + case 'shieldBreak': effects.push({ type: 'shieldBreak', magnitude: 0.25 }); break; + } + } + return effects; +} + +// ─── Basic Attack Damage ───────────────────────────────────────────────────── + +/** + * Compute basic attack damage for a golem. + * Applies elemental matchup bonus and proper armor pierce (bypasses armor fraction). + */ +export function computeBasicAttackDamage( + frame: { baseDamage: number; armorPierce: number; element?: string }, + enchantmentBonusArmorPierce: number, + enemyArmor: number, + enemyElement: string, +): number { + let dmg = frame.baseDamage; + if (frame.element) { + dmg *= getElementalBonus(frame.element, enemyElement); + } + const totalArmorPierce = Math.min(1, frame.armorPierce + enchantmentBonusArmorPierce); + const effectiveArmor = enemyArmor * (1 - totalArmorPierce); + if (effectiveArmor > 0) { + dmg *= (1 - effectiveArmor); + } + return Math.max(0, dmg); +} + +// ─── Golem Attacks Store Wrapper ───────────────────────────────────────────── + +// Import here is safe: only used inside the function body, not at module init time. +import { processGolemAttacks } from './golem-combat-actions'; // eslint-disable-line +import type { GolemCombatResult } from './golem-combat-actions'; + +interface SerializedDesign { + id: string; + name: string; + coreId: string; + frameId: string; + mindCircuitId: string; + enchantmentIds: string[]; + selectedManaTypes: string[]; + selectedSpells: string[]; +} + +/** Convenience wrapper that wires up processGolemAttacks with store callbacks. */ +export function processGolemAttacksFromStore( + activeGolems: RuntimeActiveGolem[], + golemDesigns: Record, + onDamageDealt: (damage: number) => { + rawMana: number; + elements: Record; + modifiedDamage?: number; + }, + golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean }, + enemyElement: string, + get: () => CombatStore, + set: (s: Partial) => void, +): GolemCombatResult { + return processGolemAttacks( + activeGolems, + golemDesigns, + onDamageDealt, + golemApplyDamageToRoom, + enemyElement, + () => { + const room = get().currentRoom; + const living = room.enemies.filter((e) => e.hp > 0); + if (living.length === 0) return null; + return living.reduce((lowest, e) => (e.hp < lowest.hp ? e : lowest)); + }, + (enemyId, effects) => { + const room = get().currentRoom; + const updatedEnemies = room.enemies.map((e) => { + if (e.id !== enemyId) return e; + const newEffects = [...e.activeEffects]; + for (const effect of effects) { + const idx = newEffects.findIndex((ae) => ae.type === effect.type); + if (idx >= 0) { + newEffects[idx] = { + ...newEffects[idx], + remainingDuration: 4, + magnitude: Math.max(newEffects[idx].magnitude, effect.magnitude), + }; + } else { + newEffects.push({ type: effect.type, remainingDuration: 4, magnitude: effect.magnitude, source: 'golem' }); + } + } + return { ...e, activeEffects: newEffects }; + }); + set({ currentRoom: { ...room, enemies: updatedEnemies } }); + }, + ); +}