diff --git a/README.md b/README.md index 6406fe9..e1e9a85 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,11 @@ The app lets you enter the current state of all equipment and then evaluates: - Totals effect percentages across all items, mount, and pets. - Shows calculated DPS and estimated regen to compare save slots. +3. **New item replacement comparison** + - Enter a newly rolled item and select what it would replace. + - Shows separate DPS and regen improvement percentages. + - Selecting `Pet` automatically compares against the worst current pet. + ## DPS model The app calculates DPS from entered base damage and offensive effects: diff --git a/app.js b/app.js index 702228a..ff5e70f 100644 --- a/app.js +++ b/app.js @@ -61,6 +61,16 @@ function createDefaultState() { combatStyle: "melee", recommendationCount: 8, pieces: slots.map((slot, index) => createDefaultPiece(slot, index)), + newItem: createDefaultNewItem(), + }; +} + +function createDefaultNewItem() { + return { + target: "item-0", + baseDamage: 0, + baseHealth: 0, + effects: [{ type: "", value: 1 }, { type: "", value: 1 }], }; } @@ -103,6 +113,7 @@ function normalizeState(candidate) { profile, combatStyle: ["melee", "ranged", "skill"].includes(candidate?.combatStyle) ? candidate.combatStyle : "melee", recommendationCount: Math.min(20, Math.max(3, Number(candidate?.recommendationCount || 8))), + newItem: normalizeNewItem(candidate?.newItem), pieces: fallback.pieces.map((piece, index) => enforceFixedPieceFields({ ...piece, ...(candidate?.pieces?.[index] || {}), @@ -120,6 +131,25 @@ function normalizeState(candidate) { }; } +function normalizeNewItem(candidate) { + const fallback = createDefaultNewItem(); + const effects = [0, 1].map((effectIndex) => { + const type = candidate?.effects?.[effectIndex]?.type || ""; + return { + type, + value: clampEffectValue(type, Number(candidate?.effects?.[effectIndex]?.value || 1)), + }; + }); + return { + ...fallback, + ...candidate, + target: getReplacementOptions().some((option) => option.value === candidate?.target) ? candidate.target : fallback.target, + baseDamage: Math.max(0, Number(candidate?.baseDamage || 0)), + baseHealth: Math.max(0, Number(candidate?.baseHealth || 0)), + effects, + }; +} + function normalizeProfile(profile) { if (PROFILES[profile]) return profile; if (["damage", "skill", "balanced"].includes(profile)) return "lifesteal"; @@ -136,10 +166,19 @@ function clampEffectValue(type, value) { function render() { renderControls(); renderGear(); + renderNewItemComparison(); renderSaveSlots(); updateAnalysis(); } +function getReplacementOptions() { + return [ + ...FIXED_ITEMS.map((item, index) => ({ value: `item-${index}`, label: item.name })), + { value: "mount", label: "Mount" }, + { value: "pet", label: "Pet (worst current pet)" }, + ]; +} + function renderControls() { $("#profileSelect").innerHTML = Object.entries(PROFILES).map(([id, profile]) => ``).join(""); $("#profileSelect").value = state.profile; @@ -214,6 +253,53 @@ function renderGear() { }); } +function renderNewItemComparison() { + const target = $("#newItemTarget"); + target.innerHTML = getReplacementOptions().map((option) => ``).join(""); + target.value = state.newItem.target; + + const baseFields = $("#newItemBaseFields"); + const fixed = getFixedReplacementTarget(state.newItem.target); + if (fixed) { + baseFields.innerHTML = ` + + `; + } else { + baseFields.innerHTML = ` + + `; + } + + const effects = $("#newItemEffects"); + effects.innerHTML = ""; + state.newItem.effects.forEach((effect, effectIndex) => { + const row = document.createElement("div"); + row.className = "effect-row"; + row.innerHTML = ` + + `; + row.querySelector("select").value = effect.type; + effects.appendChild(row); + }); +} + function updateAnalysis() { const profile = PROFILES[state.profile]; const totals = calculateTotals(); @@ -237,21 +323,94 @@ function updateAnalysis() { renderEffectTotals(totals, dps, survivability); renderSacrifices(pieceScores); + renderReplacementComparison(totals, dps, regenDetails.total, pieceScores); saveDraft(); } +function renderReplacementComparison(totals, currentDps, currentRegen, pieceScores) { + const targetPiece = getReplacementTargetPiece(state.newItem.target, pieceScores); + const candidate = createNewItemCandidate(state.newItem.target); + const replacementTotals = replacePieceInTotals(totals, targetPiece, candidate); + const replacementDps = calculateDps(replacementTotals, state.combatStyle); + const replacementRegen = calculateRegenDetails(replacementTotals, replacementDps).total; + const dpsDelta = percentChange(currentDps, replacementDps); + const regenDelta = percentChange(currentRegen, replacementRegen); + + $("#newItemTargetHint").textContent = targetPiece ? `Replacing ${targetPiece.name}` : "No target"; + $("#replacementDpsDelta").textContent = formatDelta(dpsDelta, "%"); + $("#replacementDpsDelta").className = deltaClass(dpsDelta); + $("#replacementRegenDelta").textContent = formatDelta(regenDelta, "%"); + $("#replacementRegenDelta").className = deltaClass(regenDelta); +} + function calculateTotals() { const totals = { baseDamage: 0, baseHealth: 0, effects: Object.fromEntries(EFFECTS.map((effect) => [effect.id, 0])) }; for (const piece of state.pieces) { - totals.baseDamage += Number(piece.baseDamage || 0); - totals.baseHealth += Number(piece.baseHealth || 0); - for (const effect of piece.effects) { - if (effect.type) totals.effects[effect.type] += clampEffectValue(effect.type, Number(effect.value)); - } + addPieceToTotals(totals, piece, 1); } return totals; } +function addPieceToTotals(totals, piece, direction) { + if (!piece) return totals; + totals.baseDamage += direction * Number(piece.baseDamage || 0); + totals.baseHealth += direction * Number(piece.baseHealth || 0); + for (const effect of piece.effects || []) { + if (effect.type) totals.effects[effect.type] += direction * clampEffectValue(effect.type, Number(effect.value)); + } + return totals; +} + +function replacePieceInTotals(totals, oldPiece, newPiece) { + const copy = { + baseDamage: totals.baseDamage, + baseHealth: totals.baseHealth, + effects: { ...totals.effects }, + }; + addPieceToTotals(copy, oldPiece, -1); + addPieceToTotals(copy, newPiece, 1); + return copy; +} + +function getFixedReplacementTarget(target) { + const match = /^item-(\d+)$/.exec(target || ""); + return match ? FIXED_ITEMS[Number(match[1])] : null; +} + +function getReplacementTargetPiece(target, pieceScores) { + if (target === "mount") return state.pieces.find((piece) => piece.type === "mount") || null; + if (target === "pet") { + return [...pieceScores] + .filter(({ piece }) => piece.type === "pet") + .sort((a, b) => a.score - b.score)[0]?.piece || null; + } + const fixed = /^item-(\d+)$/.exec(target || ""); + return fixed ? state.pieces[Number(fixed[1])] || null : null; +} + +function createNewItemCandidate(target) { + const fixed = getFixedReplacementTarget(target); + return { + id: "new-item", + type: target === "mount" ? "mount" : target === "pet" ? "pet" : "item", + name: "New item", + baseDamage: fixed?.baseType === "damage" ? fixed.baseValue : fixed ? 0 : Number(state.newItem.baseDamage || 0), + baseHealth: fixed?.baseType === "health" ? fixed.baseValue : fixed ? 0 : Number(state.newItem.baseHealth || 0), + effects: state.newItem.effects, + }; +} + +function formatDelta(value, suffix = "") { + const sign = value > 0 ? "+" : ""; + return `${sign}${fmt(value)}${suffix}`; +} + +function deltaClass(value) { + if (value > 0.05) return "delta-positive"; + if (value < -0.05) return "delta-negative"; + return "delta-neutral"; +} + function calculateDps(totals, combatStyle = "melee") { return calculateDamageDetails(totals, combatStyle).dps; } @@ -579,6 +738,31 @@ function bindEvents() { $("#downloadExport").addEventListener("click", downloadExportJson); $("#importJson").addEventListener("click", importPastedJson); $("#importFile").addEventListener("change", importUploadedJson); + $("#newItemTarget").addEventListener("change", (event) => { + state.newItem.target = event.target.value; + renderNewItemComparison(); + updateAnalysis(); + }); + $(".new-item-card").addEventListener("input", handleNewItemInput); + $(".new-item-card").addEventListener("change", handleNewItemInput); +} + +function handleNewItemInput(event) { + if (event.target.id === "newItemTarget") return; + if (event.target.dataset.newItemField) { + state.newItem[event.target.dataset.newItemField] = Math.max(0, Number(event.target.value || 0)); + } + if (event.target.dataset.newItemEffectType !== undefined) { + const index = Number(event.target.dataset.newItemEffectType); + state.newItem.effects[index].type = event.target.value; + state.newItem.effects[index].value = clampEffectValue(event.target.value, state.newItem.effects[index].value); + renderNewItemComparison(); + } + if (event.target.dataset.newItemEffectValue !== undefined) { + const index = Number(event.target.dataset.newItemEffectValue); + state.newItem.effects[index].value = clampEffectValue(state.newItem.effects[index].type, Number(event.target.value || 1)); + } + updateAnalysis(); } function handleGearInput(event) { diff --git a/index.html b/index.html index 03405cd..c7f8d48 100644 --- a/index.html +++ b/index.html @@ -89,6 +89,23 @@
+