Files
Mana-Loop/src/lib/game/stores/golem-combat-actions.test.ts
T
n8n-gitea b4b499c1b1
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
fix: split multi-type golem core upkeep across all mana types (issue #315)
2026-06-08 13:29:30 +02:00

348 lines
13 KiB
TypeScript

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