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
|
||||
Generated: 2026-06-07T21:06:17.789Z
|
||||
Generated: 2026-06-07T21:16:10.397Z
|
||||
|
||||
No circular dependencies found. ✅
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
// 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<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||
@@ -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`);
|
||||
|
||||
@@ -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