Add reversible new item swap

This commit is contained in:
Jan Bader
2026-06-16 21:00:21 +02:00
parent 75fa957981
commit 4934255e96
5 changed files with 108 additions and 3 deletions
+1
View File
@@ -130,6 +130,7 @@ The app models this with the New item comparison panel:
- Enter the new roll's base stat and effects. - Enter the new roll's base stat and effects.
- The app shows DPS and Regen change separately. - The app shows DPS and Regen change separately.
- If `Pet` is selected, the app chooses the current worst pet by piece score as the replacement target. - If `Pet` is selected, the app chooses the current worst pet by piece score as the replacement target.
- Swapping in the new roll stores the replaced piece and prior New item fields so the user can swap back.
## Profiles ## Profiles
+1
View File
@@ -26,6 +26,7 @@ For the game rules, stat caps, formulas, assumptions, and known limitations used
- choose what it would replace, - choose what it would replace,
- enter the new roll's value and effects, - enter the new roll's value and effects,
- compare DPS and Regen changes before swapping. - compare DPS and Regen changes before swapping.
- use **Swap in new item** to apply it, with **Swap back** available if you want to undo.
7. Use **Best sacrifice candidates** as a quick shortlist of low-value pieces. 7. Use **Best sacrifice candidates** as a quick shortlist of low-value pieces.
When comparing a new pet, choose `Pet`; the app automatically compares against your currently weakest pet. When comparing a new pet, choose `Pet`; the app automatically compares against your currently weakest pet.
+94 -3
View File
@@ -71,6 +71,7 @@ function createDefaultNewItem() {
baseDamage: 0, baseDamage: 0,
baseHealth: 0, baseHealth: 0,
effects: [{ type: "", value: 1 }, { type: "", value: 1 }], effects: [{ type: "", value: 1 }, { type: "", value: 1 }],
lastSwap: null,
}; };
} }
@@ -93,13 +94,14 @@ function createDefaultPiece(slot, index) {
function enforceFixedPieceFields(piece, index) { function enforceFixedPieceFields(piece, index) {
const fixed = FIXED_ITEMS[index]; const fixed = FIXED_ITEMS[index];
if (!fixed || piece.type !== "item") return piece; if (!fixed || piece.type !== "item") return piece;
const baseValue = Number(piece.baseValue || (fixed.baseType === "damage" ? piece.baseDamage : piece.baseHealth) || fixed.baseValue);
return { return {
...piece, ...piece,
name: fixed.name, name: fixed.name,
baseType: fixed.baseType, baseType: fixed.baseType,
baseValue: fixed.baseValue, baseValue,
baseDamage: fixed.baseType === "damage" ? fixed.baseValue : 0, baseDamage: fixed.baseType === "damage" ? baseValue : 0,
baseHealth: fixed.baseType === "health" ? fixed.baseValue : 0, baseHealth: fixed.baseType === "health" ? baseValue : 0,
fixedBase: true, fixedBase: true,
}; };
} }
@@ -147,6 +149,7 @@ function normalizeNewItem(candidate) {
baseDamage: Math.max(0, Number(candidate?.baseDamage || 0)), baseDamage: Math.max(0, Number(candidate?.baseDamage || 0)),
baseHealth: Math.max(0, Number(candidate?.baseHealth || 0)), baseHealth: Math.max(0, Number(candidate?.baseHealth || 0)),
effects, effects,
lastSwap: candidate?.lastSwap || null,
}; };
} }
@@ -296,6 +299,7 @@ function renderNewItemComparison() {
</label> </label>
<label>Percent <label>Percent
<input data-new-item-effect-value="${effectIndex}" type="number" min="1" step="0.1" value="${effect.value}" /> <input data-new-item-effect-value="${effectIndex}" type="number" min="1" step="0.1" value="${effect.value}" />
<span class="cap-hint"></span>
</label>`; </label>`;
row.querySelector("select").value = effect.type; row.querySelector("select").value = effect.type;
effects.appendChild(row); effects.appendChild(row);
@@ -343,6 +347,29 @@ function renderReplacementComparison(totals, currentDps, currentRegen, pieceScor
$("#replacementDpsDelta").className = deltaClass(dpsDelta); $("#replacementDpsDelta").className = deltaClass(dpsDelta);
$("#replacementRegenDelta").textContent = formatDelta(regenDelta, "%"); $("#replacementRegenDelta").textContent = formatDelta(regenDelta, "%");
$("#replacementRegenDelta").className = deltaClass(regenDelta); $("#replacementRegenDelta").className = deltaClass(regenDelta);
updateNewItemImpactHints(replacementTotals, replacementDps);
updateSwapControls();
}
function updateNewItemImpactHints(replacementTotals, replacementDps) {
document.querySelectorAll("#newItemEffects .effect-row").forEach((row, effectIndex) => {
const effect = state.newItem.effects[effectIndex];
const hint = row.querySelector(".cap-hint");
if (!effect?.type) {
hint.textContent = "Ignored";
return;
}
const current = clampEffectValue(effect.type, Number(effect.value));
const withoutThis = calculateDps(cloneTotalsWithEffectDelta(replacementTotals, effect.type, -current), state.combatStyle);
const currentGain = percentChange(withoutThis, replacementDps);
hint.textContent = `Actual: ${fmt(currentGain)}% DPS`;
});
}
function updateSwapControls() {
const hasSwap = Boolean(state.newItem.lastSwap);
$("#swapBackNewItem").disabled = !hasSwap;
$("#swapStatus").textContent = hasSwap ? `Can swap back ${state.newItem.lastSwap.piece?.name || "previous item"}.` : "";
} }
function calculateTotals() { function calculateTotals() {
@@ -404,6 +431,68 @@ function createNewItemCandidate(target) {
}; };
} }
function createPieceFromCandidate(existingPiece, candidate) {
const baseDamage = Number(candidate.baseDamage || 0);
const baseHealth = Number(candidate.baseHealth || 0);
return enforceFixedPieceFields({
...existingPiece,
baseDamage,
baseHealth,
baseType: existingPiece.fixedBase ? existingPiece.baseType : baseHealth > baseDamage ? "health" : "damage",
baseValue: existingPiece.fixedBase
? (existingPiece.baseType === "health" ? baseHealth : baseDamage)
: Math.max(baseDamage, baseHealth),
effects: candidate.effects.map((effect) => ({ ...effect })),
}, state.pieces.indexOf(existingPiece));
}
function clonePiece(piece) {
return {
...piece,
effects: (piece.effects || []).map((effect) => ({ ...effect })),
};
}
function cloneNewItem(newItem) {
return {
...newItem,
effects: newItem.effects.map((effect) => ({ ...effect })),
lastSwap: null,
};
}
function swapInNewItem() {
const profile = PROFILES[state.profile];
const totals = calculateTotals();
const pieceScores = state.pieces.map((piece) => scorePiece(piece, profile, totals));
const targetPiece = getReplacementTargetPiece(state.newItem.target, pieceScores);
if (!targetPiece) return;
const index = state.pieces.indexOf(targetPiece);
const candidate = createNewItemCandidate(state.newItem.target);
const previousPiece = clonePiece(targetPiece);
const previousNewItem = cloneNewItem(state.newItem);
state.pieces[index] = createPieceFromCandidate(targetPiece, candidate);
state.newItem = {
...previousNewItem,
lastSwap: {
index,
target: previousNewItem.target,
piece: previousPiece,
newItem: previousNewItem,
swappedAt: new Date().toISOString(),
},
};
render();
}
function swapBackNewItem() {
const lastSwap = state.newItem.lastSwap;
if (!lastSwap || !state.pieces[lastSwap.index]) return;
state.pieces[lastSwap.index] = clonePiece(lastSwap.piece);
state.newItem = cloneNewItem(lastSwap.newItem);
render();
}
function formatDelta(value, suffix = "") { function formatDelta(value, suffix = "") {
const sign = value > 0 ? "+" : ""; const sign = value > 0 ? "+" : "";
return `${sign}${fmt(value)}${suffix}`; return `${sign}${fmt(value)}${suffix}`;
@@ -747,6 +836,8 @@ function bindEvents() {
renderNewItemComparison(); renderNewItemComparison();
updateAnalysis(); updateAnalysis();
}); });
$("#swapNewItem").addEventListener("click", swapInNewItem);
$("#swapBackNewItem").addEventListener("click", swapBackNewItem);
$(".new-item-card").addEventListener("input", handleNewItemInput); $(".new-item-card").addEventListener("input", handleNewItemInput);
$(".new-item-card").addEventListener("change", handleNewItemInput); $(".new-item-card").addEventListener("change", handleNewItemInput);
} }
+5
View File
@@ -104,6 +104,11 @@
<div><span>DPS change</span><strong id="replacementDpsDelta">0</strong></div> <div><span>DPS change</span><strong id="replacementDpsDelta">0</strong></div>
<div><span>Regen change</span><strong id="replacementRegenDelta">0</strong></div> <div><span>Regen change</span><strong id="replacementRegenDelta">0</strong></div>
</div> </div>
<div class="swap-actions">
<button id="swapNewItem" type="button">Swap in new item</button>
<button id="swapBackNewItem" type="button" class="secondary" disabled>Swap back</button>
<span id="swapStatus" class="status"></span>
</div>
</section> </section>
<section> <section>
+7
View File
@@ -255,6 +255,13 @@ h3 { margin-bottom: 0; }
.delta-positive { color: #86efac; } .delta-positive { color: #86efac; }
.delta-neutral { color: var(--muted); } .delta-neutral { color: var(--muted); }
.delta-negative { color: #f87171; } .delta-negative { color: #f87171; }
.swap-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.swap-actions .status { margin: 0; }
@media (max-width: 900px) { @media (max-width: 900px) {
.hero, .results { grid-template-columns: 1fr; } .hero, .results { grid-template-columns: 1fr; }