Add new item replacement comparison

This commit is contained in:
Jan Bader
2026-06-16 20:47:35 +02:00
parent 7ce28410fc
commit a899c84791
4 changed files with 236 additions and 5 deletions
+5
View File
@@ -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:
+189 -5
View File
@@ -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]) => `<option value="${id}">${profile.label}</option>`).join("");
$("#profileSelect").value = state.profile;
@@ -214,6 +253,53 @@ function renderGear() {
});
}
function renderNewItemComparison() {
const target = $("#newItemTarget");
target.innerHTML = getReplacementOptions().map((option) => `<option value="${option.value}">${option.label}</option>`).join("");
target.value = state.newItem.target;
const baseFields = $("#newItemBaseFields");
const fixed = getFixedReplacementTarget(state.newItem.target);
if (fixed) {
baseFields.innerHTML = `
<label>Base stat
<select disabled>
<option>${fixed.baseType === "health" ? "Health" : "Damage"}</option>
</select>
</label>
<label>Value
<input type="number" value="${fixed.baseValue}" readonly />
</label>`;
} else {
baseFields.innerHTML = `
<label>Damage base
<input data-new-item-field="baseDamage" type="number" min="0" step="1" value="${state.newItem.baseDamage || 0}" />
</label>
<label>Health base
<input data-new-item-field="baseHealth" type="number" min="0" step="1" value="${state.newItem.baseHealth || 0}" />
</label>`;
}
const effects = $("#newItemEffects");
effects.innerHTML = "";
state.newItem.effects.forEach((effect, effectIndex) => {
const row = document.createElement("div");
row.className = "effect-row";
row.innerHTML = `
<label>Effect ${effectIndex + 1}
<select data-new-item-effect-type="${effectIndex}">
<option value="">None</option>
${EFFECTS.map((option) => `<option value="${option.id}">${option.label} (max ${option.cap}%)</option>`).join("")}
</select>
</label>
<label>Percent
<input data-new-item-effect-value="${effectIndex}" type="number" min="1" step="0.1" value="${effect.value}" />
</label>`;
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) {
+17
View File
@@ -89,6 +89,23 @@
<div id="effectTotals" class="effect-totals"></div>
</section>
<section class="card new-item-card" aria-labelledby="new-item-heading">
<div class="gear-title-row">
<h2 id="new-item-heading">New item comparison</h2>
<span id="newItemTargetHint" class="piece-score"></span>
</div>
<label>
Would replace
<select id="newItemTarget"></select>
</label>
<div id="newItemBaseFields" class="inline-fields"></div>
<div id="newItemEffects" class="effects"></div>
<div class="replacement-results">
<div><span>DPS change</span><strong id="replacementDpsDelta">0</strong></div>
<div><span>Regen change</span><strong id="replacementRegenDelta">0</strong></div>
</div>
</section>
<section>
<div class="section-heading">
<h2>Equipment</h2>
+25
View File
@@ -230,6 +230,31 @@ h3 { margin-bottom: 0; }
.gear-card .effect-row input[data-effect-value] { width: 100%; }
.cap-hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.2rem; }
.no-effects-note { color: var(--muted); font-size: 0.9rem; margin: 0; }
.new-item-card {
display: grid;
gap: 0.75rem;
margin: 1rem 0;
}
.replacement-results {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.replacement-results > div {
border: 1px solid var(--border);
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.05);
padding: 0.75rem;
}
.replacement-results span {
display: block;
color: var(--muted);
margin-bottom: 0.25rem;
}
.replacement-results strong { font-size: 1.35rem; }
.delta-positive { color: #86efac; }
.delta-neutral { color: var(--muted); }
.delta-negative { color: #f87171; }
@media (max-width: 900px) {
.hero, .results { grid-template-columns: 1fr; }