Files
Mana-Loop/src/lib/game/utils/combat-utils.ts
T
n8n-gitea 8dde423526
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m1s
feat: implement sword/melee auto-attack system (spec §3.1, §4.3)
- Add calcMeleeDamage() with elemental matchup for enchanted swords
- Add meleeSwordProgress per-instance accumulator to combat state
- Add melee branch in processCombatTick (no mana cost, no Executioner/Berserker)
- Add baseDamage/attackSpeed stats to all 5 sword types
- Wire equippedSwords through gameStore to combat tick pipeline
- 16 new regression tests, all 937 tests pass
2026-06-03 21:59:30 +02:00

399 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 ──────────────────────────────────────────────────
// 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 first: spell is the opposite of floor
if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective: +50% damage
// Check for weak: spell's opposite matches floor
if (ELEMENT_OPPOSITES[spellElem] === 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;
}