Calculate DPS for upgrade recommendations

This commit is contained in:
Jan Bader
2026-06-16 15:06:03 +02:00
parent d4f3d6609b
commit 690faf6c57
3 changed files with 118 additions and 10 deletions
+28 -3
View File
@@ -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: The app lets you enter the current state of all equipment and then recommends:
1. **What to increase next** 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. - 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** 2. **What is best to sacrifice instead**
- Scores every equipped piece from its base stat and effects. - 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** 3. **Overall build summary**
- Totals base damage and health. - Totals base damage and health.
- Totals effect percentages across all items, mount, and pets. - 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 ## Strategy profile
+81 -6
View File
@@ -58,6 +58,7 @@ let state = loadDraft() || createDefaultState();
function createDefaultState() { function createDefaultState() {
return { return {
profile: "balanced", profile: "balanced",
combatStyle: "melee",
recommendationCount: 8, recommendationCount: 8,
pieces: slots.map((slot, index) => ({ pieces: slots.map((slot, index) => ({
id: `${slot.type}-${index}`, id: `${slot.type}-${index}`,
@@ -77,6 +78,7 @@ function normalizeState(candidate) {
...fallback, ...fallback,
...candidate, ...candidate,
profile: PROFILES[candidate?.profile] ? candidate.profile : "balanced", 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))), recommendationCount: Math.min(20, Math.max(3, Number(candidate?.recommendationCount || 8))),
pieces: fallback.pieces.map((piece, index) => ({ pieces: fallback.pieces.map((piece, index) => ({
...piece, ...piece,
@@ -108,6 +110,7 @@ function render() {
function renderControls() { function renderControls() {
$("#profileSelect").innerHTML = Object.entries(PROFILES).map(([id, profile]) => `<option value="${id}">${profile.label}</option>`).join(""); $("#profileSelect").innerHTML = Object.entries(PROFILES).map(([id, profile]) => `<option value="${id}">${profile.label}</option>`).join("");
$("#profileSelect").value = state.profile; $("#profileSelect").value = state.profile;
$("#combatStyle").value = state.combatStyle;
$("#recommendationCount").value = state.recommendationCount; $("#recommendationCount").value = state.recommendationCount;
} }
@@ -152,19 +155,21 @@ function renderGear() {
function updateAnalysis() { function updateAnalysis() {
const profile = PROFILES[state.profile]; const profile = PROFILES[state.profile];
const totals = calculateTotals(); const totals = calculateTotals();
const dps = calculateDps(totals, state.combatStyle);
const pieceScores = state.pieces.map((piece) => scorePiece(piece, profile, totals)); const pieceScores = state.pieces.map((piece) => scorePiece(piece, profile, totals));
$("#totalDamage").textContent = fmt(totals.baseDamage); $("#totalDamage").textContent = fmt(totals.baseDamage);
$("#totalHealth").textContent = fmt(totals.baseHealth); $("#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")); $("#defenseScore").textContent = fmt(calculateGroupScore(totals, profile, ["defense", "hybrid"], "health"));
document.querySelectorAll(".gear-card").forEach((card, index) => { document.querySelectorAll(".gear-card").forEach((card, index) => {
card.querySelector(".piece-score").textContent = `Score ${fmt(pieceScores[index].score)}`; card.querySelector(".piece-score").textContent = `Score ${fmt(pieceScores[index].score)}`;
}); });
updateEffectImpactHints(totals, dps);
renderEffectTotals(totals.effects); renderEffectTotals(totals.effects);
renderUpgrades(profile); renderUpgrades(profile, totals, dps);
renderSacrifices(pieceScores); renderSacrifices(pieceScores);
saveDraft(); saveDraft();
} }
@@ -182,6 +187,42 @@ function calculateTotals() {
return totals; 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) { function calculateGroupScore(totals, profile, groups, baseKind) {
const effectScore = EFFECTS.filter((effect) => groups.includes(effect.group)) 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); .reduce((sum, effect) => sum + (totals.effects[effect.id] / effect.cap) * 100 * profile.weights[effect.id], 0);
@@ -217,7 +258,31 @@ function renderEffectTotals(effectTotals) {
`).join(""); `).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 = []; const upgrades = [];
for (const piece of state.pieces) { for (const piece of state.pieces) {
for (const effect of piece.effects) { for (const effect of piece.effects) {
@@ -226,8 +291,14 @@ function renderUpgrades(profile) {
const current = clampEffectValue(effect.type, Number(effect.value)); const current = clampEffectValue(effect.type, Number(effect.value));
const remaining = def.cap - current; const remaining = def.cap - current;
if (remaining <= 0) continue; if (remaining <= 0) continue;
const efficiency = profile.weights[effect.type] * (1 + remaining / def.cap); const plusOne = Math.min(1, remaining);
upgrades.push({ piece, def, current, remaining, efficiency }); 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; const count = state.recommendationCount;
$("#upgradeList").innerHTML = upgrades.slice(0, count).map((item) => ` $("#upgradeList").innerHTML = upgrades.slice(0, count).map((item) => `
<li><strong>${item.def.label}</strong> on ${escapeHtml(item.piece.name || item.piece.title)} <li><strong>${item.def.label}</strong> on ${escapeHtml(item.piece.name || item.piece.title)}
<small>${fmt(item.current)}% now, ${fmt(item.remaining)}% room to cap. Priority score ${fmt(item.efficiency)}.</small> <small>${fmt(item.current)}% now, ${fmt(item.remaining)}% room to cap. Next +1% gives ${fmt(item.onePointGain)}% DPS; to cap gives ${fmt(item.capGain)}% DPS.</small>
</li> </li>
`).join("") || "<li>No upgradeable effects entered yet.</li>"; `).join("") || "<li>No upgradeable effects entered yet.</li>";
} }
@@ -292,6 +363,10 @@ function bindEvents() {
state.profile = event.target.value; state.profile = event.target.value;
updateAnalysis(); updateAnalysis();
}); });
$("#combatStyle").addEventListener("change", (event) => {
state.combatStyle = event.target.value;
updateAnalysis();
});
$("#recommendationCount").addEventListener("input", (event) => { $("#recommendationCount").addEventListener("input", (event) => {
state.recommendationCount = Math.min(20, Math.max(3, Number(event.target.value || 8))); state.recommendationCount = Math.min(20, Math.max(3, Number(event.target.value || 8)));
updateAnalysis(); updateAnalysis();
+9 -1
View File
@@ -32,6 +32,14 @@
Strategy profile Strategy profile
<select id="profileSelect"></select> <select id="profileSelect"></select>
</label> </label>
<label>
Combat style for DPS
<select id="combatStyle">
<option value="melee">Melee</option>
<option value="ranged">Ranged</option>
<option value="skill">Skill</option>
</select>
</label>
<label> <label>
Recommendation count Recommendation count
<input id="recommendationCount" type="number" min="3" max="20" step="1" value="8" /> <input id="recommendationCount" type="number" min="3" max="20" step="1" value="8" />
@@ -42,7 +50,7 @@
<section class="summary-grid" aria-label="Build summary"> <section class="summary-grid" aria-label="Build summary">
<article class="metric"><span>Total damage base</span><strong id="totalDamage">0</strong></article> <article class="metric"><span>Total damage base</span><strong id="totalDamage">0</strong></article>
<article class="metric"><span>Total health base</span><strong id="totalHealth">0</strong></article> <article class="metric"><span>Total health base</span><strong id="totalHealth">0</strong></article>
<article class="metric"><span>Offense score</span><strong id="offenseScore">0</strong></article> <article class="metric"><span>Calculated DPS</span><strong id="dpsValue">0</strong></article>
<article class="metric"><span>Defense score</span><strong id="defenseScore">0</strong></article> <article class="metric"><span>Defense score</span><strong id="defenseScore">0</strong></article>
</section> </section>