Add reversible new item swap
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user