Add new item replacement comparison
This commit is contained in:
@@ -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.
|
- Totals effect percentages across all items, mount, and pets.
|
||||||
- Shows calculated DPS and estimated regen to compare save slots.
|
- 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
|
## DPS model
|
||||||
|
|
||||||
The app calculates DPS from entered base damage and offensive effects:
|
The app calculates DPS from entered base damage and offensive effects:
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ function createDefaultState() {
|
|||||||
combatStyle: "melee",
|
combatStyle: "melee",
|
||||||
recommendationCount: 8,
|
recommendationCount: 8,
|
||||||
pieces: slots.map((slot, index) => createDefaultPiece(slot, index)),
|
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,
|
profile,
|
||||||
combatStyle: ["melee", "ranged", "skill"].includes(candidate?.combatStyle) ? candidate.combatStyle : "melee",
|
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))),
|
||||||
|
newItem: normalizeNewItem(candidate?.newItem),
|
||||||
pieces: fallback.pieces.map((piece, index) => enforceFixedPieceFields({
|
pieces: fallback.pieces.map((piece, index) => enforceFixedPieceFields({
|
||||||
...piece,
|
...piece,
|
||||||
...(candidate?.pieces?.[index] || {}),
|
...(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) {
|
function normalizeProfile(profile) {
|
||||||
if (PROFILES[profile]) return profile;
|
if (PROFILES[profile]) return profile;
|
||||||
if (["damage", "skill", "balanced"].includes(profile)) return "lifesteal";
|
if (["damage", "skill", "balanced"].includes(profile)) return "lifesteal";
|
||||||
@@ -136,10 +166,19 @@ function clampEffectValue(type, value) {
|
|||||||
function render() {
|
function render() {
|
||||||
renderControls();
|
renderControls();
|
||||||
renderGear();
|
renderGear();
|
||||||
|
renderNewItemComparison();
|
||||||
renderSaveSlots();
|
renderSaveSlots();
|
||||||
updateAnalysis();
|
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() {
|
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;
|
||||||
@@ -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() {
|
function updateAnalysis() {
|
||||||
const profile = PROFILES[state.profile];
|
const profile = PROFILES[state.profile];
|
||||||
const totals = calculateTotals();
|
const totals = calculateTotals();
|
||||||
@@ -237,21 +323,94 @@ function updateAnalysis() {
|
|||||||
|
|
||||||
renderEffectTotals(totals, dps, survivability);
|
renderEffectTotals(totals, dps, survivability);
|
||||||
renderSacrifices(pieceScores);
|
renderSacrifices(pieceScores);
|
||||||
|
renderReplacementComparison(totals, dps, regenDetails.total, pieceScores);
|
||||||
saveDraft();
|
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() {
|
function calculateTotals() {
|
||||||
const totals = { baseDamage: 0, baseHealth: 0, effects: Object.fromEntries(EFFECTS.map((effect) => [effect.id, 0])) };
|
const totals = { baseDamage: 0, baseHealth: 0, effects: Object.fromEntries(EFFECTS.map((effect) => [effect.id, 0])) };
|
||||||
for (const piece of state.pieces) {
|
for (const piece of state.pieces) {
|
||||||
totals.baseDamage += Number(piece.baseDamage || 0);
|
addPieceToTotals(totals, piece, 1);
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return totals;
|
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") {
|
function calculateDps(totals, combatStyle = "melee") {
|
||||||
return calculateDamageDetails(totals, combatStyle).dps;
|
return calculateDamageDetails(totals, combatStyle).dps;
|
||||||
}
|
}
|
||||||
@@ -579,6 +738,31 @@ function bindEvents() {
|
|||||||
$("#downloadExport").addEventListener("click", downloadExportJson);
|
$("#downloadExport").addEventListener("click", downloadExportJson);
|
||||||
$("#importJson").addEventListener("click", importPastedJson);
|
$("#importJson").addEventListener("click", importPastedJson);
|
||||||
$("#importFile").addEventListener("change", importUploadedJson);
|
$("#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) {
|
function handleGearInput(event) {
|
||||||
|
|||||||
+17
@@ -89,6 +89,23 @@
|
|||||||
<div id="effectTotals" class="effect-totals"></div>
|
<div id="effectTotals" class="effect-totals"></div>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2>Equipment</h2>
|
<h2>Equipment</h2>
|
||||||
|
|||||||
+25
@@ -230,6 +230,31 @@ h3 { margin-bottom: 0; }
|
|||||||
.gear-card .effect-row input[data-effect-value] { width: 100%; }
|
.gear-card .effect-row input[data-effect-value] { width: 100%; }
|
||||||
.cap-hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.2rem; }
|
.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; }
|
.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) {
|
@media (max-width: 900px) {
|
||||||
.hero, .results { grid-template-columns: 1fr; }
|
.hero, .results { grid-template-columns: 1fr; }
|
||||||
|
|||||||
Reference in New Issue
Block a user