8dde423526
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m1s
- 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
399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
// ─── 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;
|
||
}
|