Add new item replacement comparison
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user