1aea72c013
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
- Replace generic spell-casting/void-manipulation with pact-focused disciplines - Add Pact Attunement (light): reduces pact signing time, boosts pact affinity - Add Guardian's Boon (dark): amplifies all guardian unique perks - Add pactAffinityBonus and guardianBoonMultiplier stat keys to effect system - Apply pactAffinityBonus in pact signing time calculation (gameStore) - Scale guardian boon values by guardianBoonMultiplier (combat-utils) - Guard Invoker discipline activation behind signedPacts.length > 0 - Add 'Signed guardian pact' prerequisite display in discipline-math
342 lines
12 KiB
TypeScript
342 lines
12 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 { BASE_GUARDIANS } from '../data/guardian-data';
|
|
|
|
// ─── Damage Calculation Params ──────────────────────────────────────────────
|
|
|
|
export interface DamageCalcParams {
|
|
skills: Record<string, number>;
|
|
signedPacts: number[];
|
|
}
|
|
|
|
// ─── Insight Calculation Params ─────────────────────────────────────────────
|
|
|
|
export interface InsightCalcParams {
|
|
maxFloorReached: number;
|
|
totalManaGathered: number;
|
|
signedPacts: number[];
|
|
prestigeUpgrades: Record<string, number>;
|
|
skills: Record<string, number>;
|
|
}
|
|
|
|
// ─── DPS Calculation Params ─────────────────────────────────────────────────
|
|
|
|
export interface DPSCalcParams {
|
|
skills: Record<string, number>;
|
|
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
|
|
}
|
|
|
|
// ─── 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;
|
|
const skills = state.skills;
|
|
|
|
// Base damage: spell base + skill bonus + discipline bonus
|
|
const discBaseDmg = discipline?.bonuses?.baseDamageBonus || 0;
|
|
const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5 + discBaseDmg;
|
|
|
|
// Percentage multiplier
|
|
const discDmgMult = discipline?.bonuses?.baseDamageMultiplier || 0;
|
|
const pct = 1 + (skills.arcaneFury || 0) * 0.1 + discDmgMult;
|
|
|
|
// Elemental mastery bonus
|
|
const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
|
|
|
|
// Guardian bane bonus
|
|
const isGuardianFloor = floorElem && Object.values(BASE_GUARDIANS).some(g => g.element === floorElem);
|
|
const guardianBonus = isGuardianFloor
|
|
? 1 + (skills.guardianBane || 0) * 0.2
|
|
: 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 = (skills.precision || 0) * 0.05 + 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;
|
|
}
|
|
|
|
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 + (state.skills.insightHarvest || 0) * 0.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 };
|
|
}
|
|
|
|
// ─── 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 + (state.skills.castingSpeed || 0) * 0.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;
|
|
}
|