Files
Mana-Loop/src/lib/game/utils/combat-utils.ts
T
n8n-gitea 098ec86189
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
fix: spire combat 11 high-severity discrepancies (issue #333)
D-01: Implement per-weapon cast progress (weaponCastProgress record)
D-04: Bypass Executioner/Berserker discipline specials for golem attacks
D-09: Fix lightning counter direction (lightning→water, not lightning→earth)
D-10: Add full composite element counters (blackflame/radiantflames ↔ frost/water/light/dark)
D-15: Fix Executioner to check per-enemy HP < 25% instead of floorHP ratio
D-20: Fix dodge formula to match spec (min(0.55, floor × 0.003), starts at 0)
D-22: Fix shield modifier to use flat HP pool instead of percentage barrier
D-23: Wire up applyMageBarrierRecharge in the damage pipeline
D-25: Move guardian regen from per-damage-event to once-per-tick
D-26: Add guardian armor reduction to the guardian defensive pipeline
D-31: Fix armor_corrode to be temporary (restore armor on effect expiry)
D-38: Implement AoE damage distribution across enemies

All 1069 tests pass. No files exceed 400 lines.
2026-06-08 18:25:05 +02:00

400 lines
14 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.
// ─── Combat Utilities ────────────────────────────────────────────────────────
import type { SpellCost, EquipmentInstance } from '../types';
import type { DisciplineBonuses } from './mana-utils';
import { SPELLS_DEF, ELEMENT_OPPOSITES, INCURSION_START_DAY, MAX_DAY } from '../constants';
import { ENCHANTMENT_EFFECTS } from '../data/enchantment-effects';
import { getGuardianForFloor } from '../data/guardian-encounters';
import { STATIC_GUARDIANS as BASE_GUARDIANS } from '../data/guardian-data';
// ─── Damage Calculation Params ──────────────────────────────────────────────
export interface DamageCalcParams {
signedPacts: number[];
}
// ─── Insight Calculation Params ─────────────────────────────────────────────
export interface InsightCalcParams {
maxFloorReached: number;
totalManaGathered: number;
signedPacts: number[];
prestigeUpgrades: Record<string, number>;
}
// ─── DPS Calculation Params ─────────────────────────────────────────────────
export interface DPSCalcParams {
signedPacts: number[];
equippedInstances: Record<string, string | null>;
equipmentInstances: Record<string, EquipmentInstance>;
spells: Record<string, { learned: boolean; level: number }>;
prestigeUpgrades: Record<string, number>;
}
// ─── Elemental Damage Bonus ──────────────────────────────────────────────────
// +50% if spell element opposes floor element (super effective)
// -25% if spell element matches its own opposite (weak)
export function getElementalBonus(spellElem: string, floorElem: string): number {
if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus
if (spellElem === floorElem) return 1.25; // Same element: +25% damage
// Check for super effective: spellElem is in floorElem's opposites list
const floorOpposites = ELEMENT_OPPOSITES[floorElem];
if (floorOpposites && floorOpposites.includes(spellElem)) return 1.5; // Super effective: +50% damage
// Check for weak: floorElem is in spellElem's opposites list
const spellOpposites = ELEMENT_OPPOSITES[spellElem];
if (spellOpposites && spellOpposites.includes(floorElem)) return 0.75; // Weak: -25% damage
return 1.0; // Neutral
}
/**
* Get the elemental bonus against a multi-element floor/guardian.
* Uses the minimum bonus across all elements so multi-element guardians
* are not trivially countered (each element can resist different spells).
*/
export function getMultiElementBonus(spellElem: string, floorElems: string[]): number {
if (floorElems.length === 0) return 1.0;
if (floorElems.length === 1) return getElementalBonus(spellElem, floorElems[0]);
return Math.min(...floorElems.map(e => getElementalBonus(spellElem, e)));
}
// ─── Boon Bonuses ─────────────────────────────────────────────────────────────
export interface BoonBonuses {
maxMana: number;
manaRegen: number;
castingSpeed: number;
elementalDamage: number;
rawDamage: number;
critChance: number;
critDamage: number;
spellEfficiency: number;
manaGain: number;
insightGain: number;
studySpeed: number;
prestigeInsight: number;
}
// Helper to calculate total boon bonuses from signed pacts
export function getBoonBonuses(
signedPacts: number[],
guardianBoonMultiplier: number = 1.0,
): BoonBonuses {
const bonuses: BoonBonuses = {
maxMana: 0,
manaRegen: 0,
castingSpeed: 0,
elementalDamage: 0,
rawDamage: 0,
critChance: 0,
critDamage: 0,
spellEfficiency: 0,
manaGain: 0,
insightGain: 0,
studySpeed: 0,
prestigeInsight: 0,
};
for (const floor of signedPacts) {
const guardian = getGuardianForFloor(floor);
if (!guardian) continue;
for (const boon of guardian.boons) {
let value = boon.value * guardianBoonMultiplier;
switch (boon.type) {
case 'maxMana':
bonuses.maxMana += value;
break;
case 'manaRegen':
bonuses.manaRegen += value;
break;
case 'castingSpeed':
bonuses.castingSpeed += value;
break;
case 'elementalDamage':
bonuses.elementalDamage += value;
break;
case 'rawDamage':
bonuses.rawDamage += value;
break;
case 'critChance':
bonuses.critChance += value;
break;
case 'critDamage':
bonuses.critDamage += value;
break;
case 'spellEfficiency':
bonuses.spellEfficiency += value;
break;
case 'manaGain':
bonuses.manaGain += value;
break;
case 'insightGain':
bonuses.insightGain += value;
break;
case 'studySpeed':
bonuses.studySpeed += value;
break;
case 'prestigeInsight':
bonuses.prestigeInsight += value;
break;
}
}
}
return bonuses;
}
// ─── Damage Calculation ───────────────────────────────────────────────────────
export function calcDamage(
state: DamageCalcParams,
spellId: string,
floorElem?: string,
discipline?: DisciplineBonuses,
): number {
const sp = SPELLS_DEF[spellId];
if (!sp) return 5;
// Base damage: spell base + discipline bonus
const discBaseDmg = discipline?.bonuses?.baseDamageBonus || 0;
const baseDmg = sp.dmg + discBaseDmg;
// Percentage multiplier
const discDmgMult = discipline?.bonuses?.baseDamageMultiplier || 0;
const pct = 1 + discDmgMult;
// Elemental mastery bonus
const elemMasteryBonus = 1;
// Guardian bane bonus
const isGuardianFloor = floorElem && Object.values(BASE_GUARDIANS).some(g => g.element.includes(floorElem));
const guardianBonus = 1;
// Get boon bonuses from pacts
const boons = getBoonBonuses(state.signedPacts);
// Apply raw damage and elemental damage bonuses
const rawDamageMult = 1 + boons.rawDamage / 100;
const elemDamageMult = 1 + boons.elementalDamage / 100;
// Apply crit chance and damage from boons
const critChance = boons.critChance / 100;
const critDamageMult = 1.5 + boons.critDamage / 100;
let damage = baseDmg * pct * elemMasteryBonus * guardianBonus * rawDamageMult * elemDamageMult;
// Apply elemental bonus if floor element provided
if (floorElem) {
damage *= getElementalBonus(sp.elem, floorElem);
}
// Apply crit
if (Math.random() < critChance) {
damage *= critDamageMult;
}
// Final NaN guard — if anything produced NaN, return a safe fallback
if (!Number.isFinite(damage)) return 5;
return damage;
}
// ─── Insight Calculation ──────────────────────────────────────────────────────
export function calcInsight(state: InsightCalcParams, discipline?: DisciplineBonuses): number {
const pu = state.prestigeUpgrades;
const discInsightBonus = discipline?.bonuses?.insightGainBonus || 0;
const skillBonus = 1 + discInsightBonus;
// Get boon bonuses for insight gain
const boons = getBoonBonuses(state.signedPacts);
const boonInsightMult = 1 + boons.insightGain / 100;
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus * boonInsightMult;
// Add prestigeInsight bonus per loop
const prestigeInsightBonus = boons.prestigeInsight;
return Math.floor(
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150 + prestigeInsightBonus) * mult
);
}
// ─── Incursion Strength ───────────────────────────────────────────────────────
export function getIncursionStrength(day: number, hour: number): number {
if (day < INCURSION_START_DAY) return 0;
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
return Math.min(0.95, (totalHours / maxHours) * 0.95);
}
// ─── Spell Cost Helpers ───────────────────────────────────────────────────────
// Check if player can afford spell cost
export function canAffordSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): boolean {
if (cost.type === 'raw') {
return rawMana >= cost.amount;
} else {
const elem = elements[cost.element || ''];
return elem && elem.unlocked && elem.current >= cost.amount;
}
}
// Deduct spell cost from appropriate mana pool
export function deductSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
const newElements = { ...elements };
if (cost.type === 'raw') {
const deductedAmount = Math.min(rawMana, cost.amount);
return { rawMana: rawMana - deductedAmount, elements: newElements };
} else if (cost.element && newElements[cost.element]) {
const elem = newElements[cost.element];
const deductedAmount = Math.min(elem.current, cost.amount);
newElements[cost.element] = {
...elem,
current: elem.current - deductedAmount
};
return { rawMana, elements: newElements };
}
return { rawMana, elements: newElements };
}
// ─── Melee Damage Calculation (spec §4.3) ────────────────────────────────────
/**
* Map from sword enchantment specialId to element type for elemental matchup.
*/
const SWORD_ENCHANT_ELEMENT: Record<string, string> = {
fireBlade: 'fire',
frostBlade: 'frost',
lightningBlade: 'lightning',
voidBlade: 'void',
};
/**
* Calculate melee damage for a sword attack (spec §4.3).
*
* Formula: baseDmg = sword.baseDamage + sword.elementalEnchantDamage
* damage = baseDmg × getElementalBonus(sword.enchantElement, enemy.element)
*
* No crit, no discipline damage bonus for melee in v1.
* attackSpeedMult from equipment does apply to meleeProgress accumulation.
*/
export function calcMeleeDamage(
swordInstance: EquipmentInstance,
swordType: { stats?: { baseDamage?: number } },
enemyElement: string,
): number {
const baseDmg = swordType.stats?.baseDamage || 5;
// Determine enchant element from sword's enchantments
let enchantElement: string | null = null;
for (const ench of swordInstance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (effectDef?.effect.type === 'special' && effectDef.effect.specialId) {
const elem = SWORD_ENCHANT_ELEMENT[effectDef.effect.specialId];
if (elem) {
enchantElement = elem;
break;
}
}
}
// Apply elemental bonus if sword has an elemental enchant
if (enchantElement) {
return baseDmg * getElementalBonus(enchantElement, enemyElement);
}
return baseDmg;
}
// ─── Equipment Spell Helpers ──────────────────────────────────────────────────
// Return type for active equipment spells with source equipment
export interface ActiveEquipmentSpell {
spellId: string;
equipmentId: string;
}
// Get active spells from equipped equipment
export function getActiveEquipmentSpells(
equippedInstances: Record<string, string | null>,
equipmentInstances: Record<string, EquipmentInstance>
): ActiveEquipmentSpell[] {
const equippedIds = Object.values(equippedInstances || {}).filter((id): id is string => id !== null);
const spells: ActiveEquipmentSpell[] = [];
for (const id of equippedIds) {
const instance = equipmentInstances[id];
if (!instance) continue;
for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
const exists = spells.some(s => s.spellId === effectDef.effect.spellId && s.equipmentId === id);
if (!exists) {
spells.push({ spellId: effectDef.effect.spellId, equipmentId: id });
}
}
}
}
return spells;
}
// ─── DPS Calculation ──────────────────────────────────────────────────────────
// Compute total DPS from all sources (spells, equipment, etc.)
export function getTotalDPS(
state: DPSCalcParams,
upgradeEffects: { spellDamageBonus?: number; attackSpeedMultiplier?: number; [key: string]: unknown },
floorElem?: string,
discipline?: DisciplineBonuses,
): number {
let totalDPS = 0;
// Get active equipment spells
const activeSpells = getActiveEquipmentSpells(state.equippedInstances, state.equipmentInstances);
// Calculate DPS for each active spell
for (const { spellId } of activeSpells) {
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) continue;
// Calculate damage per cast
const damage = calcDamage(state, spellId, floorElem, discipline);
// Get cast speed (spells per second)
const baseCastTime = spellDef.baseCastTime || 1.0;
const castingSpeedBonus = 1;
const equipmentAttackSpeed = upgradeEffects.attackSpeedMultiplier || 1;
const castTime = baseCastTime / (castingSpeedBonus * equipmentAttackSpeed);
// DPS for this spell
const spellDPS = damage / castTime;
totalDPS += spellDPS;
}
// Add equipment DPS bonuses from upgrade effects
if (upgradeEffects.spellDamageBonus) {
totalDPS *= (1 + upgradeEffects.spellDamageBonus / 100);
}
return totalDPS;
}