fix: golem combat runtime - elemental matchup, enchantment effects, spell damage/cost, armor pierce (issue #313)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
This commit is contained in:
@@ -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> = {}): 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<string, never> },
|
||||
) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user