// ─── 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); }); });