Files
Mana-Loop/src/lib/game/stores/golem-combat-helpers.ts
T
n8n-gitea 33be133813
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m1s
fix: resolve 3 critical bugs — #354 attunement ReferenceError, #353 preparation mana exploit, #352 golem mana wipe
- #354: unlockAttunement now uses _get() instead of undefined 'state' variable
- #353: startPreparing now deducts raw mana from the mana store after validation
- #352: processGolemAttacks/processBasicAttack accept current mana as params instead of initializing to 0/{}
- Updated golem-combat-actions.test.ts to pass new currentRawMana/currentElements params
- Added regression tests for all 3 bugs (16 new tests, all passing)
2026-06-10 20:49:46 +02:00

221 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── 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.
* Formula per spire-combat spec §9.4: dmg = frame.baseDamage × (1 + frame.armorPierce)
* Also applies elemental matchup bonus and enchantment armor pierce bonus.
* Enemy armor reduction is handled separately in onDamageDealt.
*/
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 = frame.armorPierce + enchantmentBonusArmorPierce;
dmg *= (1 + totalArmorPierce);
return Math.max(0, dmg);
}
// ─── Basic Attack Processing ────────────────────────────────────────────────
export interface BasicAttackContext {
frame: { baseDamage: number; armorPierce: number; element?: string; aoeTargets: number };
bonusArmorPierce: number;
enchantmentEffects: GolemEnchantmentEffect[];
enemyElement: string;
getTargetEnemy: () => EnemyState | null;
getTargetEnemies: () => EnemyState[];
onDamageDealt: (damage: number, skipSpecials?: boolean) => {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
modifiedDamage?: number;
};
applyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean };
onApplyEnchantmentEffects: (enemyId: string, effects: GolemEnchantmentEffect[]) => void;
}
export interface BasicAttackResult {
rawMana: number;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
floorHP: number;
floorMaxHP: number;
totalDamageDealt: number;
}
/**
* Process a single basic attack (AoE or single-target) for a golem.
* AoE frames distribute damage across up to frame.aoeTargets enemies (spec §11).
* Single-target frames attack the lowest-HP enemy.
*/
export function processBasicAttack(ctx: BasicAttackContext, currentRawMana: number, currentElements: Record<string, { current: number; max: number; unlocked: boolean }>): BasicAttackResult {
let rawMana = currentRawMana;
let elements: Record<string, { current: number; max: number; unlocked: boolean }> = { ...currentElements };
let floorHP = 0;
let floorMaxHP = 0;
let totalDamageDealt = 0;
if (ctx.frame.aoeTargets > 1) {
const allEnemies = ctx.getTargetEnemies();
if (allEnemies.length > 0) {
const targets = allEnemies.slice(0, ctx.frame.aoeTargets);
const dmgPerTarget = computeBasicAttackDamage(ctx.frame, ctx.bonusArmorPierce, 0, ctx.enemyElement) / targets.length;
for (const target of targets) {
if (ctx.enchantmentEffects.length > 0) {
ctx.onApplyEnchantmentEffects(target.id, ctx.enchantmentEffects);
}
const dmgResult = ctx.onDamageDealt(dmgPerTarget, true);
const finalDamage = dmgResult.modifiedDamage || dmgPerTarget;
if (Number.isFinite(finalDamage)) {
const roomResult = ctx.applyDamageToRoom(finalDamage);
floorHP = roomResult.floorHP;
floorMaxHP = roomResult.floorMaxHP;
totalDamageDealt += Math.max(0, finalDamage);
rawMana = dmgResult.rawMana;
elements = dmgResult.elements;
}
}
}
} else {
const targetEnemy = ctx.getTargetEnemy();
const dmg = computeBasicAttackDamage(ctx.frame, ctx.bonusArmorPierce, 0, ctx.enemyElement);
if (ctx.enchantmentEffects.length > 0 && targetEnemy) {
ctx.onApplyEnchantmentEffects(targetEnemy.id, ctx.enchantmentEffects);
}
const dmgResult = ctx.onDamageDealt(dmg, true);
const finalDamage = dmgResult.modifiedDamage || dmg;
if (Number.isFinite(finalDamage)) {
const roomResult = ctx.applyDamageToRoom(finalDamage);
floorHP = roomResult.floorHP;
floorMaxHP = roomResult.floorMaxHP;
totalDamageDealt += Math.max(0, finalDamage);
rawMana = dmgResult.rawMana;
elements = dmgResult.elements;
}
}
return { rawMana, elements, floorHP, floorMaxHP, totalDamageDealt };
}
// ─── 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,
currentRawMana: number,
currentElements: Record<string, { current: number; max: number; unlocked: boolean }>,
): 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));
},
() => {
const room = get().currentRoom;
return room.enemies.filter((e) => e.hp > 0);
},
(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 } });
},
currentRawMana,
currentElements,
);
}