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:
@@ -1,4 +1,4 @@
|
|||||||
# Circular Dependencies
|
# Circular Dependencies
|
||||||
Generated: 2026-06-07T21:06:17.789Z
|
Generated: 2026-06-07T21:16:10.397Z
|
||||||
|
|
||||||
No circular dependencies found. ✅
|
No circular dependencies found. ✅
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_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.",
|
"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."
|
"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": [
|
"crafting-fabricator.ts": [
|
||||||
"data/fabricator-recipes.ts",
|
"data/fabricator-recipes.ts",
|
||||||
|
"effects/discipline-effects.ts",
|
||||||
"stores/manaStore.ts",
|
"stores/manaStore.ts",
|
||||||
"types.ts"
|
"types.ts"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -385,7 +385,10 @@ Mana-Loop/
|
|||||||
│ │ │ │ ├── gameLoopActions.ts
|
│ │ │ │ ├── gameLoopActions.ts
|
||||||
│ │ │ │ ├── gameStore.ts
|
│ │ │ │ ├── gameStore.ts
|
||||||
│ │ │ │ ├── gameStore.types.ts
|
│ │ │ │ ├── gameStore.types.ts
|
||||||
|
│ │ │ │ ├── golem-combat-actions.test.ts
|
||||||
│ │ │ │ ├── golem-combat-actions.ts
|
│ │ │ │ ├── golem-combat-actions.ts
|
||||||
|
│ │ │ │ ├── golem-combat-helpers.test.ts
|
||||||
|
│ │ │ │ ├── golem-combat-helpers.ts
|
||||||
│ │ │ │ ├── golemancy-actions.ts
|
│ │ │ │ ├── golemancy-actions.ts
|
||||||
│ │ │ │ ├── golemancy-combat.test.ts
|
│ │ │ │ ├── golemancy-combat.test.ts
|
||||||
│ │ │ │ ├── index.ts
|
│ │ │ │ ├── index.ts
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { getFloorMaxHP, getFloorElement, getMultiElementBonus, calcDamage, calcM
|
|||||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
||||||
import {
|
import {
|
||||||
processGolemMaintenance,
|
processGolemMaintenance,
|
||||||
processGolemAttacks,
|
|
||||||
processGolemManaRegen,
|
processGolemManaRegen,
|
||||||
} from './golem-combat-actions';
|
} from './golem-combat-actions';
|
||||||
|
import { processGolemAttacksFromStore } from './golem-combat-helpers';
|
||||||
import { applyDamageToRoom } from './combat-damage';
|
import { applyDamageToRoom } from './combat-damage';
|
||||||
|
|
||||||
// ─── Result Type ───────────────────────────────────────────────────────────────
|
// ─── Result Type ───────────────────────────────────────────────────────────────
|
||||||
@@ -306,11 +306,14 @@ export function processCombatTick(
|
|||||||
|
|
||||||
// ─── Golem attacks (spec §11) ───────────────────────────────────────────
|
// ─── Golem attacks (spec §11) ───────────────────────────────────────────
|
||||||
if (activeGolems.length > 0 && floorHP > 0) {
|
if (activeGolems.length > 0 && floorHP > 0) {
|
||||||
const golemResult = processGolemAttacks(
|
const golemResult = processGolemAttacksFromStore(
|
||||||
activeGolems,
|
activeGolems,
|
||||||
golemDesigns,
|
golemDesigns,
|
||||||
onDamageDealt,
|
onDamageDealt,
|
||||||
golemApplyDamageToRoom,
|
golemApplyDamageToRoom,
|
||||||
|
getFloorElement(currentFloor),
|
||||||
|
get,
|
||||||
|
set,
|
||||||
);
|
);
|
||||||
rawMana = golemResult.rawMana;
|
rawMana = golemResult.rawMana;
|
||||||
elements = golemResult.elements;
|
elements = golemResult.elements;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -3,14 +3,18 @@
|
|||||||
// All external data is passed in as parameters (no cross-store getState() calls).
|
// All external data is passed in as parameters (no cross-store getState() calls).
|
||||||
// Implements spec §§10-14: summoning, maintenance, combat, mana, duration.
|
// 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 { CORES, FRAMES, MIND_CIRCUITS } from '../data/golems';
|
||||||
import { computeGolemStats, getGolemSlots } from '../data/golems/utils';
|
import { computeGolemStats, getGolemSlots } from '../data/golems/utils';
|
||||||
|
import {
|
||||||
|
resolveEnchantmentEffects,
|
||||||
|
computeBasicAttackDamage,
|
||||||
|
} from './golem-combat-helpers';
|
||||||
|
import type { GolemEnchantmentEffect } from './golem-combat-helpers';
|
||||||
import type {
|
import type {
|
||||||
RuntimeActiveGolem,
|
RuntimeActiveGolem,
|
||||||
GolemLoadoutEntry,
|
GolemLoadoutEntry,
|
||||||
EnemyState,
|
EnemyState,
|
||||||
ActiveEffect,
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
@@ -60,22 +64,17 @@ export function summonGolemsOnRoomEntry(
|
|||||||
const newActiveGolems = [...existingActiveGolems];
|
const newActiveGolems = [...existingActiveGolems];
|
||||||
const logMessages: string[] = [];
|
const logMessages: string[] = [];
|
||||||
|
|
||||||
const activeCount = newActiveGolems.length;
|
|
||||||
const baseSlots = getGolemSlots(fabricatorLevel);
|
const baseSlots = getGolemSlots(fabricatorLevel);
|
||||||
const totalSlots = Math.min(7, baseSlots + disciplineSlotsBonus);
|
const totalSlots = Math.min(7, baseSlots + disciplineSlotsBonus);
|
||||||
|
|
||||||
for (const entry of loadout) {
|
for (const entry of loadout) {
|
||||||
if (!entry.enabled) continue;
|
if (!entry.enabled) continue;
|
||||||
|
|
||||||
// Check slot availability (max 7 total per AC-1)
|
|
||||||
if (newActiveGolems.length >= totalSlots) {
|
if (newActiveGolems.length >= totalSlots) {
|
||||||
logMessages.push('No golem slots available');
|
logMessages.push('No golem slots available');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const design = entry.design as SerializedDesign;
|
const design = entry.design as SerializedDesign;
|
||||||
|
|
||||||
// Resolve components
|
|
||||||
const core = CORES[design.coreId];
|
const core = CORES[design.coreId];
|
||||||
const frame = FRAMES[design.frameId];
|
const frame = FRAMES[design.frameId];
|
||||||
const circuit = MIND_CIRCUITS[design.mindCircuitId];
|
const circuit = MIND_CIRCUITS[design.mindCircuitId];
|
||||||
@@ -84,23 +83,20 @@ export function summonGolemsOnRoomEntry(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if already active
|
|
||||||
const alreadyActive = newActiveGolems.some((ag) => ag.designId === entry.designId);
|
const alreadyActive = newActiveGolems.some((ag) => ag.designId === entry.designId);
|
||||||
if (alreadyActive) continue;
|
if (alreadyActive) continue;
|
||||||
|
|
||||||
// Build component-based design for cost calculation
|
|
||||||
const stats = computeGolemStats({
|
const stats = computeGolemStats({
|
||||||
id: design.id,
|
id: design.id,
|
||||||
name: design.name,
|
name: design.name,
|
||||||
core: { ...core, manaTypes: design.selectedManaTypes.length > 0 ? design.selectedManaTypes : core.manaTypes },
|
core: { ...core, manaTypes: design.selectedManaTypes.length > 0 ? design.selectedManaTypes : core.manaTypes },
|
||||||
frame,
|
frame,
|
||||||
mindCircuit: circuit,
|
mindCircuit: circuit,
|
||||||
enchantments: [], // Simplified — enchantments resolved by ID in full implementation
|
enchantments: [],
|
||||||
selectedManaTypes: design.selectedManaTypes,
|
selectedManaTypes: design.selectedManaTypes,
|
||||||
selectedSpells: design.selectedSpells,
|
selectedSpells: design.selectedSpells,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check affordability
|
|
||||||
let canAfford = true;
|
let canAfford = true;
|
||||||
for (const cost of stats.totalSummonCost) {
|
for (const cost of stats.totalSummonCost) {
|
||||||
if (cost.type === 'raw') {
|
if (cost.type === 'raw') {
|
||||||
@@ -116,7 +112,6 @@ export function summonGolemsOnRoomEntry(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduct summon cost
|
|
||||||
for (const cost of stats.totalSummonCost) {
|
for (const cost of stats.totalSummonCost) {
|
||||||
if (cost.type === 'raw') {
|
if (cost.type === 'raw') {
|
||||||
newRawMana -= cost.amount;
|
newRawMana -= cost.amount;
|
||||||
@@ -140,12 +135,7 @@ export function summonGolemsOnRoomEntry(
|
|||||||
logMessages.push(`${entry.design.name} summoned`);
|
logMessages.push(`${entry.design.name} summoned`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { rawMana: newRawMana, elements: newElements, activeGolems: newActiveGolems, logMessages };
|
||||||
rawMana: newRawMana,
|
|
||||||
elements: newElements,
|
|
||||||
activeGolems: newActiveGolems,
|
|
||||||
logMessages,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Maintenance Upkeep (spec §13) ───────────────────────────────────────────
|
// ─── Maintenance Upkeep (spec §13) ───────────────────────────────────────────
|
||||||
@@ -174,25 +164,17 @@ export function processGolemMaintenance(
|
|||||||
for (const golem of activeGolems) {
|
for (const golem of activeGolems) {
|
||||||
const design = golemDesigns[golem.designId];
|
const design = golemDesigns[golem.designId];
|
||||||
if (!design) continue;
|
if (!design) continue;
|
||||||
|
|
||||||
const core = CORES[design.coreId];
|
const core = CORES[design.coreId];
|
||||||
if (!core) continue;
|
if (!core) continue;
|
||||||
|
|
||||||
// Upkeep per tick = (manaRegen × 2) × HOURS_PER_TICK
|
|
||||||
const upkeepPerTick = core.manaRegen * 2 * HOURS_PER_TICK;
|
const upkeepPerTick = core.manaRegen * 2 * HOURS_PER_TICK;
|
||||||
const upkeepElement = core.primaryManaType;
|
const upkeepElement = core.primaryManaType;
|
||||||
|
|
||||||
const elem = upkeepElement ? newElements[upkeepElement] : null;
|
const elem = upkeepElement ? newElements[upkeepElement] : null;
|
||||||
|
|
||||||
if (upkeepElement && elem && elem.unlocked && elem.current >= upkeepPerTick) {
|
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);
|
maintainedGolems.push(golem);
|
||||||
} else if (!upkeepElement && newRawMana >= upkeepPerTick) {
|
} else if (!upkeepElement && newRawMana >= upkeepPerTick) {
|
||||||
// Deduct from raw mana
|
|
||||||
newRawMana -= upkeepPerTick;
|
newRawMana -= upkeepPerTick;
|
||||||
maintainedGolems.push(golem);
|
maintainedGolems.push(golem);
|
||||||
} else if (upkeepElement && (!elem || !elem.unlocked || elem.current < upkeepPerTick)) {
|
} else if (upkeepElement && (!elem || !elem.unlocked || elem.current < upkeepPerTick)) {
|
||||||
@@ -202,12 +184,7 @@ export function processGolemMaintenance(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { rawMana: newRawMana, elements: newElements, maintainedGolems, logMessages };
|
||||||
rawMana: newRawMana,
|
|
||||||
elements: newElements,
|
|
||||||
maintainedGolems,
|
|
||||||
logMessages,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Golem Mana Regen (spec §12) ────────────────────────────────────────────
|
// ─── Golem Mana Regen (spec §12) ────────────────────────────────────────────
|
||||||
@@ -222,15 +199,10 @@ export function processGolemManaRegen(
|
|||||||
return activeGolems.map((golem) => {
|
return activeGolems.map((golem) => {
|
||||||
const design = golemDesigns[golem.designId];
|
const design = golemDesigns[golem.designId];
|
||||||
if (!design) return golem;
|
if (!design) return golem;
|
||||||
|
|
||||||
const core = CORES[design.coreId];
|
const core = CORES[design.coreId];
|
||||||
if (!core) return golem;
|
if (!core) return golem;
|
||||||
|
|
||||||
const manaGain = core.manaRegen * HOURS_PER_TICK;
|
const manaGain = core.manaRegen * HOURS_PER_TICK;
|
||||||
return {
|
return { ...golem, currentMana: Math.min(core.manaCapacity, golem.currentMana + manaGain) };
|
||||||
...golem,
|
|
||||||
currentMana: Math.min(core.manaCapacity, golem.currentMana + manaGain),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +212,13 @@ export function processGolemManaRegen(
|
|||||||
* Process golem attacks for one combat tick.
|
* Process golem attacks for one combat tick.
|
||||||
* Each golem accumulates attackProgress and fires when >= 1.
|
* Each golem accumulates attackProgress and fires when >= 1.
|
||||||
* Supports spell casting via Mind Circuit behavior.
|
* 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(
|
export function processGolemAttacks(
|
||||||
activeGolems: RuntimeActiveGolem[],
|
activeGolems: RuntimeActiveGolem[],
|
||||||
@@ -250,6 +229,9 @@ export function processGolemAttacks(
|
|||||||
modifiedDamage?: number;
|
modifiedDamage?: number;
|
||||||
},
|
},
|
||||||
applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
|
enemyElement: string,
|
||||||
|
getTargetEnemy: () => EnemyState | null,
|
||||||
|
onApplyEnchantmentEffects: (enemyId: string, effects: GolemEnchantmentEffect[]) => void,
|
||||||
): GolemCombatResult {
|
): GolemCombatResult {
|
||||||
let rawMana = 0;
|
let rawMana = 0;
|
||||||
let elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
let elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||||
@@ -257,7 +239,6 @@ export function processGolemAttacks(
|
|||||||
let floorMaxHP = 0;
|
let floorMaxHP = 0;
|
||||||
const logMessages: string[] = [];
|
const logMessages: string[] = [];
|
||||||
let totalDamageDealt = 0;
|
let totalDamageDealt = 0;
|
||||||
|
|
||||||
const updatedGolems: RuntimeActiveGolem[] = [];
|
const updatedGolems: RuntimeActiveGolem[] = [];
|
||||||
|
|
||||||
for (const golem of activeGolems) {
|
for (const golem of activeGolems) {
|
||||||
@@ -269,6 +250,9 @@ export function processGolemAttacks(
|
|||||||
const circuit = MIND_CIRCUITS[design.mindCircuitId];
|
const circuit = MIND_CIRCUITS[design.mindCircuitId];
|
||||||
if (!core || !frame || !circuit) continue;
|
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;
|
let attackProgress = golem.attackProgress + HOURS_PER_TICK * frame.attackSpeed;
|
||||||
const updatedGolem = { ...golem };
|
const updatedGolem = { ...golem };
|
||||||
let safetyCounter = 0;
|
let safetyCounter = 0;
|
||||||
@@ -279,34 +263,47 @@ export function processGolemAttacks(
|
|||||||
if (circuit.spellSlots > 0 && design.selectedSpells.length > 0) {
|
if (circuit.spellSlots > 0 && design.selectedSpells.length > 0) {
|
||||||
const spellIdx = updatedGolem.spellCastIndex % design.selectedSpells.length;
|
const spellIdx = updatedGolem.spellCastIndex % design.selectedSpells.length;
|
||||||
const spellId = design.selectedSpells[spellIdx];
|
const spellId = design.selectedSpells[spellIdx];
|
||||||
|
const spellDef = spellId ? SPELLS_DEF[spellId] : null;
|
||||||
|
|
||||||
// Spell casting simplified — full implementation needs spell cost/effect lookup
|
if (spellDef) {
|
||||||
if (spellId && updatedGolem.currentMana >= 10) {
|
const spellManaCost = spellDef.cost.amount;
|
||||||
// Cast spell: damage scaled by magic affinity
|
if (updatedGolem.currentMana >= spellManaCost) {
|
||||||
const spellDmg = 20 * frame.magicAffinity; // Placeholder base spell damage
|
// FIX #1: Use actual spell damage * magic affinity
|
||||||
updatedGolem.currentMana -= 10;
|
const spellDmg = spellDef.dmg * frame.magicAffinity;
|
||||||
updatedGolem.spellCastIndex = (updatedGolem.spellCastIndex + 1) % design.selectedSpells.length;
|
// FIX #2: Deduct actual spell mana cost
|
||||||
|
updatedGolem.currentMana -= spellManaCost;
|
||||||
|
updatedGolem.spellCastIndex = (updatedGolem.spellCastIndex + 1) % design.selectedSpells.length;
|
||||||
|
|
||||||
const dmgResult = onDamageDealt(spellDmg);
|
const dmgResult = onDamageDealt(spellDmg);
|
||||||
const finalDamage = dmgResult.modifiedDamage || spellDmg;
|
const finalDamage = dmgResult.modifiedDamage || spellDmg;
|
||||||
|
|
||||||
if (Number.isFinite(finalDamage)) {
|
if (Number.isFinite(finalDamage)) {
|
||||||
const roomResult = applyDamageToRoom(finalDamage);
|
const roomResult = applyDamageToRoom(finalDamage);
|
||||||
floorHP = roomResult.floorHP;
|
floorHP = roomResult.floorHP;
|
||||||
floorMaxHP = roomResult.floorMaxHP;
|
floorMaxHP = roomResult.floorMaxHP;
|
||||||
totalDamageDealt += Math.max(0, finalDamage);
|
totalDamageDealt += Math.max(0, finalDamage);
|
||||||
rawMana = dmgResult.rawMana;
|
rawMana = dmgResult.rawMana;
|
||||||
elements = dmgResult.elements;
|
elements = dmgResult.elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
attackProgress -= 1;
|
||||||
|
safetyCounter++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
attackProgress -= 1;
|
|
||||||
safetyCounter++;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic attack
|
// 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 dmgResult = onDamageDealt(dmg);
|
||||||
const finalDamage = dmgResult.modifiedDamage || dmg;
|
const finalDamage = dmgResult.modifiedDamage || dmg;
|
||||||
@@ -328,13 +325,7 @@ export function processGolemAttacks(
|
|||||||
updatedGolems.push(updatedGolem);
|
updatedGolems.push(updatedGolem);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { rawMana, elements, activeGolems: updatedGolems, logMessages, totalDamageDealt };
|
||||||
rawMana,
|
|
||||||
elements,
|
|
||||||
activeGolems: updatedGolems,
|
|
||||||
logMessages,
|
|
||||||
totalDamageDealt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Room Duration Countdown (spec §14) ─────────────────────────────────────
|
// ─── Room Duration Countdown (spec §14) ─────────────────────────────────────
|
||||||
@@ -358,12 +349,10 @@ export function countdownGolemRoomDuration(
|
|||||||
for (const golem of activeGolems) {
|
for (const golem of activeGolems) {
|
||||||
const design = golemDesigns[golem.designId];
|
const design = golemDesigns[golem.designId];
|
||||||
if (!design) continue;
|
if (!design) continue;
|
||||||
|
|
||||||
const core = CORES[design.coreId];
|
const core = CORES[design.coreId];
|
||||||
if (!core) continue;
|
if (!core) continue;
|
||||||
|
|
||||||
const newRoomsRemaining = golem.roomsRemaining - 1;
|
const newRoomsRemaining = golem.roomsRemaining - 1;
|
||||||
|
|
||||||
if (newRoomsRemaining <= 0) {
|
if (newRoomsRemaining <= 0) {
|
||||||
dismissedNames.push(design.name);
|
dismissedNames.push(design.name);
|
||||||
logMessages.push(`${design.name} has faded after ${core.maxRoomDuration} rooms`);
|
logMessages.push(`${design.name} has faded after ${core.maxRoomDuration} rooms`);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, SerializedDesign>,
|
||||||
|
onDamageDealt: (damage: number) => {
|
||||||
|
rawMana: number;
|
||||||
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
|
modifiedDamage?: number;
|
||||||
|
},
|
||||||
|
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
|
||||||
|
enemyElement: string,
|
||||||
|
get: () => CombatStore,
|
||||||
|
set: (s: Partial<CombatState>) => 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 } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user