From 690faf6c57dc63daf98798e42ab5f72aa4bc05d0 Mon Sep 17 00:00:00 2001 From: Jan Bader Date: Tue, 16 Jun 2026 15:06:03 +0200 Subject: [PATCH] Calculate DPS for upgrade recommendations --- README.md | 31 +++++++++++++++++-- app.js | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++---- index.html | 10 ++++++- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e72c308..b41ccd9 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,10 @@ Effects start at `1%` and can be upgraded up to their cap. The app lets you enter the current state of all equipment and then recommends: 1. **What to increase next** - - Prioritizes effects by weighted strategic value. + - Prioritizes effects by actual DPS gain for the selected combat style. - Accounts for each effect's remaining room to grow before cap. - - Highlights high-value underleveled effects. + - Shows the DPS gain from the next `+1%` and from upgrading to cap. + - Crit damage has no DPS value until crit chance is above `0%`. 2. **What is best to sacrifice instead** - Scores every equipped piece from its base stat and effects. @@ -51,7 +52,31 @@ The app lets you enter the current state of all equipment and then recommends: 3. **Overall build summary** - Totals base damage and health. - Totals effect percentages across all items, mount, and pets. - - Shows a simple offensive and defensive score to compare save slots. + - Shows calculated DPS and a defensive score to compare save slots. + +## DPS model + +The app calculates DPS from entered base damage and offensive effects: + +```text +DPS = base damage + * (1 + damage%) + * (1 + selected style damage%) + * (1 + attack speed%) + * (1 + double chance%) + * (1 + crit chance% * crit damage%) + * skill cooldown multiplier, only for Skill style +``` + +The selected combat style controls which specialized damage bonus is used: + +- Melee uses melee damage. +- Ranged uses ranged damage. +- Skill uses skill damage and skill cooldown reduction. + +All additive bonuses are evaluated against the current total. For example, if the build already has `+100% melee damage`, adding another `+50% melee damage` increases the melee multiplier from `2.0x` to `2.5x`, which is an actual `+25%` DPS increase rather than `+50%`. + +Each item effect row displays its actual current DPS contribution and the marginal DPS gain from the next `+1%` upgrade. ## Strategy profile diff --git a/app.js b/app.js index a33d0ef..24d1774 100644 --- a/app.js +++ b/app.js @@ -58,6 +58,7 @@ let state = loadDraft() || createDefaultState(); function createDefaultState() { return { profile: "balanced", + combatStyle: "melee", recommendationCount: 8, pieces: slots.map((slot, index) => ({ id: `${slot.type}-${index}`, @@ -77,6 +78,7 @@ function normalizeState(candidate) { ...fallback, ...candidate, profile: PROFILES[candidate?.profile] ? candidate.profile : "balanced", + combatStyle: ["melee", "ranged", "skill"].includes(candidate?.combatStyle) ? candidate.combatStyle : "melee", recommendationCount: Math.min(20, Math.max(3, Number(candidate?.recommendationCount || 8))), pieces: fallback.pieces.map((piece, index) => ({ ...piece, @@ -108,6 +110,7 @@ function render() { function renderControls() { $("#profileSelect").innerHTML = Object.entries(PROFILES).map(([id, profile]) => ``).join(""); $("#profileSelect").value = state.profile; + $("#combatStyle").value = state.combatStyle; $("#recommendationCount").value = state.recommendationCount; } @@ -152,19 +155,21 @@ function renderGear() { function updateAnalysis() { const profile = PROFILES[state.profile]; const totals = calculateTotals(); + const dps = calculateDps(totals, state.combatStyle); const pieceScores = state.pieces.map((piece) => scorePiece(piece, profile, totals)); $("#totalDamage").textContent = fmt(totals.baseDamage); $("#totalHealth").textContent = fmt(totals.baseHealth); - $("#offenseScore").textContent = fmt(calculateGroupScore(totals, profile, ["offense", "skill", "hybrid"], "damage")); + $("#dpsValue").textContent = fmt(dps); $("#defenseScore").textContent = fmt(calculateGroupScore(totals, profile, ["defense", "hybrid"], "health")); document.querySelectorAll(".gear-card").forEach((card, index) => { card.querySelector(".piece-score").textContent = `Score ${fmt(pieceScores[index].score)}`; }); + updateEffectImpactHints(totals, dps); renderEffectTotals(totals.effects); - renderUpgrades(profile); + renderUpgrades(profile, totals, dps); renderSacrifices(pieceScores); saveDraft(); } @@ -182,6 +187,42 @@ function calculateTotals() { return totals; } +function calculateDps(totals, combatStyle = "melee") { + const effects = totals.effects; + const baseDamage = Math.max(0, totals.baseDamage); + const globalDamage = 1 + effects.damage / 100; + const styleDamage = 1 + getStyleDamageBonus(effects, combatStyle) / 100; + const attackSpeed = 1 + effects.attackSpeed / 100; + const doubleHit = 1 + effects.doubleChance / 100; + const critChance = effects.critChance / 100; + const critDamage = effects.critDamage / 100; + const crit = 1 + critChance * critDamage; + const cooldown = combatStyle === "skill" ? 1 / Math.max(0.05, 1 - effects.skillCooldown / 100) : 1; + return baseDamage * globalDamage * styleDamage * attackSpeed * doubleHit * crit * cooldown; +} + +function getStyleDamageBonus(effects, combatStyle) { + if (combatStyle === "ranged") return effects.rangedDamage; + if (combatStyle === "skill") return effects.skillDamage; + return effects.meleeDamage; +} + +function cloneTotalsWithEffectDelta(totals, effectType, delta) { + return { + baseDamage: totals.baseDamage, + baseHealth: totals.baseHealth, + effects: { + ...totals.effects, + [effectType]: Math.max(0, (totals.effects[effectType] || 0) + delta), + }, + }; +} + +function percentChange(from, to) { + if (from <= 0) return to > 0 ? 100 : 0; + return ((to / from) - 1) * 100; +} + function calculateGroupScore(totals, profile, groups, baseKind) { const effectScore = EFFECTS.filter((effect) => groups.includes(effect.group)) .reduce((sum, effect) => sum + (totals.effects[effect.id] / effect.cap) * 100 * profile.weights[effect.id], 0); @@ -217,7 +258,31 @@ function renderEffectTotals(effectTotals) { `).join(""); } -function renderUpgrades(profile) { +function updateEffectImpactHints(totals, currentDps) { + document.querySelectorAll(".gear-card").forEach((card, pieceIndex) => { + const piece = state.pieces[pieceIndex]; + card.querySelectorAll(".effect-row").forEach((row, effectIndex) => { + const effect = piece.effects[effectIndex]; + const hint = row.querySelector(".cap-hint"); + if (!effect.type) { + hint.textContent = "Ignored"; + return; + } + const def = effectById(effect.type); + const current = clampEffectValue(effect.type, Number(effect.value)); + const withoutThis = calculateDps(cloneTotalsWithEffectDelta(totals, effect.type, -current), state.combatStyle); + const currentGain = percentChange(withoutThis, currentDps); + const remaining = Math.max(0, def.cap - current); + const plusOne = Math.min(1, remaining); + const plusOneGain = plusOne > 0 + ? percentChange(currentDps, calculateDps(cloneTotalsWithEffectDelta(totals, effect.type, plusOne), state.combatStyle)) + : 0; + hint.textContent = `Cap: ${def.cap}%. Actual now: ${fmt(currentGain)}% DPS. Next +${fmt(plusOne)}%: ${fmt(plusOneGain)}% DPS.`; + }); + }); +} + +function renderUpgrades(profile, totals, currentDps) { const upgrades = []; for (const piece of state.pieces) { for (const effect of piece.effects) { @@ -226,8 +291,14 @@ function renderUpgrades(profile) { const current = clampEffectValue(effect.type, Number(effect.value)); const remaining = def.cap - current; if (remaining <= 0) continue; - const efficiency = profile.weights[effect.type] * (1 + remaining / def.cap); - upgrades.push({ piece, def, current, remaining, efficiency }); + const plusOne = Math.min(1, remaining); + const onePointDps = calculateDps(cloneTotalsWithEffectDelta(totals, effect.type, plusOne), state.combatStyle); + const capDps = calculateDps(cloneTotalsWithEffectDelta(totals, effect.type, remaining), state.combatStyle); + const onePointGain = percentChange(currentDps, onePointDps); + const capGain = percentChange(currentDps, capDps); + const fallback = profile.weights[effect.type] * 0.001; + const efficiency = capGain || onePointGain || fallback; + upgrades.push({ piece, def, current, remaining, efficiency, onePointGain, capGain }); } } @@ -235,7 +306,7 @@ function renderUpgrades(profile) { const count = state.recommendationCount; $("#upgradeList").innerHTML = upgrades.slice(0, count).map((item) => `
  • ${item.def.label} on ${escapeHtml(item.piece.name || item.piece.title)} - ${fmt(item.current)}% now, ${fmt(item.remaining)}% room to cap. Priority score ${fmt(item.efficiency)}. + ${fmt(item.current)}% now, ${fmt(item.remaining)}% room to cap. Next +1% gives ${fmt(item.onePointGain)}% DPS; to cap gives ${fmt(item.capGain)}% DPS.
  • `).join("") || "
  • No upgradeable effects entered yet.
  • "; } @@ -292,6 +363,10 @@ function bindEvents() { state.profile = event.target.value; updateAnalysis(); }); + $("#combatStyle").addEventListener("change", (event) => { + state.combatStyle = event.target.value; + updateAnalysis(); + }); $("#recommendationCount").addEventListener("input", (event) => { state.recommendationCount = Math.min(20, Math.max(3, Number(event.target.value || 8))); updateAnalysis(); diff --git a/index.html b/index.html index b8f5148..2e0dbf9 100644 --- a/index.html +++ b/index.html @@ -32,6 +32,14 @@ Strategy profile +