1122 lines
44 KiB
JavaScript
1122 lines
44 KiB
JavaScript
/* ==========================================================================
|
|
Data definitions and shared helpers
|
|
========================================================================== */
|
|
|
|
const EFFECTS = [
|
|
{ id: "critChance", label: "Crit chance", cap: 12, group: "offense" },
|
|
{ id: "critDamage", label: "Crit damage", cap: 80, group: "offense" },
|
|
{ id: "blockChance", label: "Block chance", cap: 5, group: "defense" },
|
|
{ id: "healthRegen", label: "Health regen", cap: 4, group: "defense" },
|
|
{ id: "lifesteal", label: "Lifesteal", cap: 20, group: "hybrid" },
|
|
{ id: "doubleChance", label: "Double chance", cap: 20, group: "offense" },
|
|
{ id: "damage", label: "Damage", cap: 15, group: "offense" },
|
|
{ id: "meleeDamage", label: "Melee damage", cap: 50, group: "offense" },
|
|
{ id: "rangedDamage", label: "Ranged damage", cap: 15, group: "offense" },
|
|
{ id: "attackSpeed", label: "Attack speed", cap: 40, group: "offense" },
|
|
{ id: "skillDamage", label: "Skill damage", cap: 30, group: "skill" },
|
|
{ id: "skillCooldown", label: "Skill cooldown reduction", cap: 7, group: "skill" },
|
|
{ id: "health", label: "Health", cap: 15, group: "defense" },
|
|
];
|
|
|
|
const PROFILE_IDS = Object.freeze({ LIFESTEAL: "lifesteal", REGEN: "regen" });
|
|
const COMBAT_STYLES = Object.freeze({ MELEE: "melee", RANGED: "ranged", SKILL: "skill" });
|
|
const PIECE_TYPES = Object.freeze({ ITEM: "item", MOUNT: "mount", PET: "pet", SKILL: "skill" });
|
|
const REPLACEMENT_TARGETS = Object.freeze({ MOUNT: "mount", PET: "pet" });
|
|
const OPTIMIZATION_TARGETS = Object.freeze({ DAMAGE: "damage", LIFESTEAL_REGEN: "lifestealRegen", DAMAGE_LIFESTEAL_MIX: "damageLifestealMix" });
|
|
|
|
const PROFILES = {
|
|
[PROFILE_IDS.LIFESTEAL]: {
|
|
label: "Lifesteal (DPS + Lifesteal)",
|
|
weights: { critChance: 10, critDamage: 6, blockChance: 2, healthRegen: 2, lifesteal: 10, doubleChance: 10, damage: 10, meleeDamage: 6, rangedDamage: 6, attackSpeed: 9, skillDamage: 7, skillCooldown: 7, health: 3 },
|
|
baseDamage: 1.25,
|
|
baseHealth: 0.45,
|
|
},
|
|
[PROFILE_IDS.REGEN]: {
|
|
label: "Regen (Health + Regen)",
|
|
weights: { critChance: 4, critDamage: 2, blockChance: 7, healthRegen: 12, lifesteal: 5, doubleChance: 4, damage: 4, meleeDamage: 2, rangedDamage: 2, attackSpeed: 3, skillDamage: 3, skillCooldown: 3, health: 12 },
|
|
baseDamage: 0.45,
|
|
baseHealth: 1.35,
|
|
},
|
|
};
|
|
|
|
const STORAGE_KEY = "forgeMasterOptimizer.v1";
|
|
const DRAFT_KEY = "forgeMasterOptimizer.draft.v1";
|
|
const EXPORT_VERSION = 1;
|
|
// Canonical fixed core item definitions; GAME.md references this list to avoid duplicated values.
|
|
const FIXED_ITEMS = [
|
|
{ name: "Helmet", baseType: "health", baseValue: 1930000 },
|
|
{ name: "Armor", baseType: "health", baseValue: 1890000 },
|
|
{ name: "Gloves", baseType: "damage", baseValue: 237000 },
|
|
{ name: "Collar", baseType: "damage", baseValue: 237000 },
|
|
{ name: "Ring", baseType: "damage", baseValue: 244000 },
|
|
{ name: "Weapon", baseType: "damage", baseValue: 398000 },
|
|
{ name: "Boots", baseType: "health", baseValue: 1850000 },
|
|
{ name: "Belt", baseType: "health", baseValue: 1150000 },
|
|
];
|
|
const slots = [
|
|
...FIXED_ITEMS.map((item, i) => ({ type: PIECE_TYPES.ITEM, title: `Item ${i + 1}`, fixed: item })),
|
|
{ type: PIECE_TYPES.MOUNT, title: "Mount" },
|
|
...Array.from({ length: 3 }, (_, i) => ({ type: PIECE_TYPES.PET, title: `Pet ${i + 1}` })),
|
|
{ type: PIECE_TYPES.SKILL, title: "Skill", noEffects: true },
|
|
];
|
|
|
|
const $ = (selector) => document.querySelector(selector);
|
|
const NUMBER_SUFFIXES = Object.freeze({ k: 1_000, m: 1_000_000, b: 1_000_000_000 });
|
|
|
|
function parseNumberInput(value, fallback = 0) {
|
|
if (typeof value === "number") return Number.isFinite(value) ? value : fallback;
|
|
const text = String(value ?? "").trim().replace(/,/g, "").toLowerCase();
|
|
if (!text) return fallback;
|
|
const match = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*([kmb])?$/.exec(text);
|
|
if (!match) return fallback;
|
|
const parsed = Number(match[1]) * (NUMBER_SUFFIXES[match[2]] || 1);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
function formatCompactNumber(value) {
|
|
const number = Number(value || 0);
|
|
if (!Number.isFinite(number)) return "0";
|
|
const abs = Math.abs(number);
|
|
const suffix = abs >= NUMBER_SUFFIXES.b ? "b" : abs >= NUMBER_SUFFIXES.m ? "m" : abs >= NUMBER_SUFFIXES.k ? "k" : "";
|
|
const divisor = suffix ? NUMBER_SUFFIXES[suffix] : 1;
|
|
const scaled = number / divisor;
|
|
return `${Number(scaled.toPrecision(3))}${suffix}`;
|
|
}
|
|
|
|
const fmt = formatCompactNumber;
|
|
const effectById = (id) => EFFECTS.find((effect) => effect.id === id);
|
|
|
|
let state = loadDraft() || createDefaultState();
|
|
|
|
/* ==========================================================================
|
|
State creation and normalization
|
|
========================================================================== */
|
|
|
|
function createDefaultState() {
|
|
return {
|
|
profile: PROFILE_IDS.LIFESTEAL,
|
|
combatStyle: COMBAT_STYLES.MELEE,
|
|
optimizationTarget: OPTIMIZATION_TARGETS.DAMAGE,
|
|
recommendationCount: 8,
|
|
pieces: slots.map((slot, index) => createDefaultPiece(slot, index)),
|
|
newItem: createDefaultNewItem(),
|
|
};
|
|
}
|
|
|
|
function createDefaultNewItem() {
|
|
return {
|
|
target: "item-0",
|
|
targetPieceId: null,
|
|
baseDamage: 0,
|
|
baseHealth: 0,
|
|
effects: [{ type: "", value: 1 }, { type: "", value: 1 }],
|
|
};
|
|
}
|
|
|
|
function createDefaultPiece(slot, index) {
|
|
return {
|
|
id: `${slot.type}-${index}`,
|
|
type: slot.type,
|
|
title: slot.title,
|
|
name: slot.fixed?.name || slot.title,
|
|
baseType: slot.fixed?.baseType || (index % 3 === 1 ? "health" : "damage"),
|
|
baseValue: slot.fixed?.baseValue || 0,
|
|
baseDamage: slot.fixed?.baseType === "damage" ? slot.fixed.baseValue : 0,
|
|
baseHealth: slot.fixed?.baseType === "health" ? slot.fixed.baseValue : 0,
|
|
fixedBase: Boolean(slot.fixed),
|
|
noEffects: Boolean(slot.noEffects),
|
|
effects: slot.noEffects ? [] : [{ type: "", value: 1 }, { type: "", value: 1 }],
|
|
};
|
|
}
|
|
|
|
function enforceFixedPieceFields(piece, index) {
|
|
const fixed = FIXED_ITEMS[index];
|
|
if (!fixed || piece.type !== PIECE_TYPES.ITEM) return piece;
|
|
const baseValue = Number(piece.baseValue || (fixed.baseType === "damage" ? piece.baseDamage : piece.baseHealth) || fixed.baseValue);
|
|
return {
|
|
...piece,
|
|
name: fixed.name,
|
|
baseType: fixed.baseType,
|
|
baseValue,
|
|
baseDamage: fixed.baseType === "damage" ? baseValue : 0,
|
|
baseHealth: fixed.baseType === "health" ? baseValue : 0,
|
|
fixedBase: true,
|
|
};
|
|
}
|
|
|
|
function normalizeState(candidate) {
|
|
const fallback = createDefaultState();
|
|
const profile = normalizeProfile(candidate?.profile);
|
|
return {
|
|
...fallback,
|
|
...candidate,
|
|
profile,
|
|
combatStyle: Object.values(COMBAT_STYLES).includes(candidate?.combatStyle) ? candidate.combatStyle : COMBAT_STYLES.MELEE,
|
|
optimizationTarget: Object.values(OPTIMIZATION_TARGETS).includes(candidate?.optimizationTarget) ? candidate.optimizationTarget : OPTIMIZATION_TARGETS.DAMAGE,
|
|
recommendationCount: Math.min(20, Math.max(3, Number(candidate?.recommendationCount || 8))),
|
|
newItem: normalizeNewItem(candidate?.newItem),
|
|
pieces: fallback.pieces.map((piece, index) => normalizePiece(piece, candidate?.pieces?.[index], index)),
|
|
};
|
|
}
|
|
|
|
function normalizePiece(fallbackPiece, candidatePiece, index) {
|
|
const baseDamage = Number(candidatePiece?.baseDamage ?? (candidatePiece?.baseType === "damage" ? candidatePiece?.baseValue : fallbackPiece.baseDamage) ?? 0);
|
|
const baseHealth = Number(candidatePiece?.baseHealth ?? (candidatePiece?.baseType === "health" ? candidatePiece?.baseValue : fallbackPiece.baseHealth) ?? 0);
|
|
const noEffects = fallbackPiece.noEffects || Boolean(candidatePiece?.noEffects);
|
|
return enforceFixedPieceFields({
|
|
...fallbackPiece,
|
|
...(candidatePiece || {}),
|
|
baseDamage,
|
|
baseHealth,
|
|
noEffects,
|
|
effects: noEffects ? [] : normalizeEffects(candidatePiece?.effects),
|
|
}, index);
|
|
}
|
|
|
|
function normalizeNewItem(candidate) {
|
|
const fallback = createDefaultNewItem();
|
|
return {
|
|
...fallback,
|
|
...candidate,
|
|
target: getReplacementOptions().some((option) => option.value === candidate?.target) ? candidate.target : fallback.target,
|
|
targetPieceId: candidate?.targetPieceId || null,
|
|
baseDamage: Math.max(0, Number(candidate?.baseDamage || 0)),
|
|
baseHealth: Math.max(0, Number(candidate?.baseHealth || 0)),
|
|
effects: normalizeEffects(candidate?.effects),
|
|
};
|
|
}
|
|
|
|
function normalizeEffects(candidateEffects) {
|
|
return [0, 1].map((effectIndex) => {
|
|
const type = candidateEffects?.[effectIndex]?.type || "";
|
|
return {
|
|
type,
|
|
value: clampEffectValue(type, Number(candidateEffects?.[effectIndex]?.value || 1)),
|
|
};
|
|
});
|
|
}
|
|
|
|
function normalizeProfile(profile) {
|
|
if (PROFILES[profile]) return profile;
|
|
if (["damage", "skill", "balanced"].includes(profile)) return PROFILE_IDS.LIFESTEAL;
|
|
if (profile === "survival") return PROFILE_IDS.REGEN;
|
|
return PROFILE_IDS.LIFESTEAL;
|
|
}
|
|
|
|
function clampEffectValue(type, value) {
|
|
if (!type) return 1;
|
|
const effect = effectById(type);
|
|
return Math.min(effect.cap, Math.max(1, Number.isFinite(value) ? value : 1));
|
|
}
|
|
|
|
/* ==========================================================================
|
|
Rendering
|
|
========================================================================== */
|
|
|
|
function render() {
|
|
renderControls();
|
|
renderGear();
|
|
renderNewItemComparison();
|
|
renderSaveSlots();
|
|
updateAnalysis();
|
|
}
|
|
|
|
function getReplacementOptions() {
|
|
return [
|
|
...FIXED_ITEMS.map((item, index) => ({ value: `item-${index}`, label: item.name })),
|
|
{ value: REPLACEMENT_TARGETS.MOUNT, label: "Mount" },
|
|
{ value: REPLACEMENT_TARGETS.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;
|
|
$("#combatStyle").value = state.combatStyle;
|
|
$("#optimizationTarget").value = state.optimizationTarget;
|
|
$("#recommendationCount").value = fmt(state.recommendationCount);
|
|
}
|
|
|
|
function createEffectRow(effect, effectIndex, typeAttribute, valueAttribute, showInitialHint = true) {
|
|
const row = document.createElement("div");
|
|
row.className = "effect-row";
|
|
row.innerHTML = `
|
|
<label>Effect ${effectIndex + 1}
|
|
<select ${typeAttribute}="${effectIndex}">
|
|
<option value="">None</option>
|
|
${EFFECTS.map((option) => `<option value="${option.id}">${option.label} (max ${option.cap}%)</option>`).join("")}
|
|
</select>
|
|
</label>
|
|
<label>Percent
|
|
<input ${valueAttribute}="${effectIndex}" type="text" inputmode="decimal" value="${fmt(effect.value)}" />
|
|
<span class="cap-hint"></span>
|
|
</label>`;
|
|
row.querySelector("select").value = effect.type;
|
|
if (showInitialHint) {
|
|
row.querySelector(".cap-hint").textContent = effect.type ? `Cap: ${effectById(effect.type).cap}%` : "Ignored";
|
|
}
|
|
return row;
|
|
}
|
|
|
|
function renderGear() {
|
|
const grid = $("#gearGrid");
|
|
const template = $("#gearTemplate");
|
|
grid.innerHTML = "";
|
|
|
|
state.pieces.forEach((piece, pieceIndex) => {
|
|
const node = template.content.firstElementChild.cloneNode(true);
|
|
node.dataset.index = pieceIndex;
|
|
node.querySelector("h3").textContent = piece.name;
|
|
node.querySelector('[data-field="name"]').value = piece.name;
|
|
const baseFields = node.querySelector(".inline-fields");
|
|
if (piece.fixedBase) {
|
|
node.classList.add("fixed-piece");
|
|
node.querySelector('[data-field="name"]').readOnly = true;
|
|
baseFields.innerHTML = `
|
|
<label>Base stat
|
|
<select data-field="baseType" disabled>
|
|
<option value="damage">Damage</option>
|
|
<option value="health">Health</option>
|
|
</select>
|
|
</label>
|
|
<label>Value
|
|
<input data-field="baseValue" type="text" inputmode="decimal" />
|
|
</label>`;
|
|
baseFields.querySelector('[data-field="baseType"]').value = piece.baseType;
|
|
baseFields.querySelector('[data-field="baseValue"]').value = fmt(piece.baseValue);
|
|
} else {
|
|
baseFields.innerHTML = `
|
|
<label>Damage base
|
|
<input data-field="baseDamage" type="text" inputmode="decimal" />
|
|
</label>
|
|
<label>Health base
|
|
<input data-field="baseHealth" type="text" inputmode="decimal" />
|
|
</label>`;
|
|
baseFields.querySelector('[data-field="baseDamage"]').value = fmt(piece.baseDamage || 0);
|
|
baseFields.querySelector('[data-field="baseHealth"]').value = fmt(piece.baseHealth || 0);
|
|
}
|
|
|
|
const effects = node.querySelector(".effects");
|
|
if (piece.noEffects) {
|
|
effects.innerHTML = '<p class="no-effects-note">Base stats only. No effects.</p>';
|
|
} else {
|
|
piece.effects.forEach((effect, effectIndex) => {
|
|
effects.appendChild(createEffectRow(effect, effectIndex, "data-effect-type", "data-effect-value"));
|
|
});
|
|
}
|
|
|
|
grid.appendChild(node);
|
|
});
|
|
}
|
|
|
|
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) {
|
|
const field = fixed.baseType === "health" ? "baseHealth" : "baseDamage";
|
|
const value = Number(state.newItem[field] || fixed.baseValue);
|
|
baseFields.innerHTML = `
|
|
<label>Base stat
|
|
<select disabled>
|
|
<option>${fixed.baseType === "health" ? "Health" : "Damage"}</option>
|
|
</select>
|
|
</label>
|
|
<label>Value
|
|
<input data-new-item-field="${field}" type="text" inputmode="decimal" value="${fmt(value)}" />
|
|
</label>`;
|
|
} else {
|
|
baseFields.innerHTML = `
|
|
<label>Damage base
|
|
<input data-new-item-field="baseDamage" type="text" inputmode="decimal" value="${fmt(state.newItem.baseDamage || 0)}" />
|
|
</label>
|
|
<label>Health base
|
|
<input data-new-item-field="baseHealth" type="text" inputmode="decimal" value="${fmt(state.newItem.baseHealth || 0)}" />
|
|
</label>`;
|
|
}
|
|
|
|
const effects = $("#newItemEffects");
|
|
effects.innerHTML = "";
|
|
state.newItem.effects.forEach((effect, effectIndex) => {
|
|
effects.appendChild(createEffectRow(effect, effectIndex, "data-new-item-effect-type", "data-new-item-effect-value", false));
|
|
});
|
|
}
|
|
|
|
function updateAnalysis() {
|
|
const profile = PROFILES[state.profile];
|
|
const totals = calculateTotals();
|
|
const damageDetails = calculateDamageDetails(totals, state.combatStyle);
|
|
const dps = damageDetails.dps;
|
|
const regenDetails = calculateRegenDetails(totals, dps);
|
|
const survivability = calculateSurvivability(totals);
|
|
const pieceScores = state.pieces.map((piece) => scorePiece(piece, profile, totals));
|
|
|
|
$("#totalDamage").textContent = fmt(totals.baseDamage);
|
|
$("#totalHealth").textContent = fmt(totals.baseHealth);
|
|
$("#dpsValue").textContent = fmt(dps);
|
|
$("#hitDamageDetails").innerHTML = `Hit ${fmt(damageDetails.singleHit)}<br>Crit ${fmt(damageDetails.critHit)}`;
|
|
$("#regenValue").textContent = fmt(regenDetails.total);
|
|
$("#regenDetails").innerHTML = `Health regen ${fmt(regenDetails.healthRegen)}<br>Lifesteal ${fmt(regenDetails.lifesteal)}`;
|
|
|
|
document.querySelectorAll(".gear-card").forEach((card, index) => {
|
|
card.querySelector(".piece-score").textContent = `Score ${fmt(pieceScores[index].score)}`;
|
|
});
|
|
updateEffectImpactHints(totals, dps);
|
|
|
|
renderEffectTotals(totals, dps, survivability);
|
|
renderOptimalDamageSetup(totals, dps);
|
|
renderSacrifices(pieceScores);
|
|
renderReplacementComparison(totals, dps, regenDetails.total, pieceScores);
|
|
saveDraft();
|
|
}
|
|
|
|
function renderReplacementComparison(totals, currentDps, currentRegen, pieceScores) {
|
|
const { targetPiece, newItemPiece } = getReplacementContext(pieceScores);
|
|
const replacementTotals = replacePieceInTotals(totals, targetPiece, newItemPiece);
|
|
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);
|
|
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() {
|
|
$("#swapStatus").textContent = "Press Swap again to exchange back.";
|
|
}
|
|
|
|
/* ==========================================================================
|
|
Replacement comparison and swap helpers
|
|
========================================================================== */
|
|
|
|
function calculateTotals() {
|
|
const totals = { baseDamage: 0, baseHealth: 0, effects: Object.fromEntries(EFFECTS.map((effect) => [effect.id, 0])) };
|
|
for (const piece of state.pieces) {
|
|
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 getReplacementContext(pieceScores) {
|
|
const target = state.newItem.target;
|
|
const targetPiece = getReplacementTargetPiece(target, pieceScores);
|
|
return {
|
|
target,
|
|
targetPiece,
|
|
targetIndex: targetPiece ? state.pieces.indexOf(targetPiece) : -1,
|
|
newItemPiece: createNewItemCandidate(target),
|
|
};
|
|
}
|
|
|
|
function getReplacementTargetPiece(target, pieceScores) {
|
|
if (state.newItem.targetPieceId) {
|
|
const pinned = state.pieces.find((piece) => piece.id === state.newItem.targetPieceId);
|
|
if (pinned) return pinned;
|
|
}
|
|
if (target === REPLACEMENT_TARGETS.MOUNT) return state.pieces.find((piece) => piece.type === PIECE_TYPES.MOUNT) || null;
|
|
if (target === REPLACEMENT_TARGETS.PET) {
|
|
return [...pieceScores]
|
|
.filter(({ piece }) => piece.type === PIECE_TYPES.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);
|
|
const fixedBaseDamage = fixed?.baseType === "damage" ? Number(state.newItem.baseDamage || fixed.baseValue) : 0;
|
|
const fixedBaseHealth = fixed?.baseType === "health" ? Number(state.newItem.baseHealth || fixed.baseValue) : 0;
|
|
return {
|
|
id: "new-item",
|
|
type: target === REPLACEMENT_TARGETS.MOUNT ? PIECE_TYPES.MOUNT : target === REPLACEMENT_TARGETS.PET ? PIECE_TYPES.PET : PIECE_TYPES.ITEM,
|
|
name: "New item",
|
|
baseDamage: fixed ? fixedBaseDamage : Number(state.newItem.baseDamage || 0),
|
|
baseHealth: fixed ? fixedBaseHealth : Number(state.newItem.baseHealth || 0),
|
|
effects: state.newItem.effects,
|
|
};
|
|
}
|
|
|
|
function createPieceFromCandidate(existingPiece, newItemPiece) {
|
|
const baseDamage = Number(newItemPiece.baseDamage || 0);
|
|
const baseHealth = Number(newItemPiece.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: newItemPiece.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 })),
|
|
};
|
|
}
|
|
|
|
function swapInNewItem() {
|
|
const profile = PROFILES[state.profile];
|
|
const totals = calculateTotals();
|
|
const pieceScores = state.pieces.map((piece) => scorePiece(piece, profile, totals));
|
|
const { targetPiece, targetIndex, newItemPiece } = getReplacementContext(pieceScores);
|
|
if (!targetPiece || targetIndex < 0) return;
|
|
const swappedOutPiece = clonePiece(targetPiece);
|
|
const previousNewItemState = cloneNewItem(state.newItem);
|
|
state.pieces[targetIndex] = createPieceFromCandidate(targetPiece, newItemPiece);
|
|
state.newItem = createNewItemFromPiece(swappedOutPiece, previousNewItemState.target, targetPiece.id);
|
|
render();
|
|
}
|
|
|
|
function createNewItemFromPiece(piece, target, targetPieceId = null) {
|
|
return {
|
|
target,
|
|
targetPieceId,
|
|
baseDamage: Number(piece.baseDamage || 0),
|
|
baseHealth: Number(piece.baseHealth || 0),
|
|
effects: [0, 1].map((index) => ({
|
|
type: piece.effects?.[index]?.type || "",
|
|
value: piece.effects?.[index]?.value || 1,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/* ==========================================================================
|
|
Calculations and scoring
|
|
========================================================================== */
|
|
|
|
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 = COMBAT_STYLES.MELEE) {
|
|
return calculateDamageDetails(totals, combatStyle).dps;
|
|
}
|
|
|
|
function calculateDamageDetails(totals, combatStyle = COMBAT_STYLES.MELEE) {
|
|
const effects = totals.effects;
|
|
const baseDamage = Math.max(0, totals.baseDamage);
|
|
const globalDamage = 1 + effects.damage / 100;
|
|
const styleDamage = 1 + getStyleDamageBonus(effects, combatStyle) / 100;
|
|
const singleHit = baseDamage * globalDamage * styleDamage;
|
|
const attacksPerSecond = (1 + effects.attackSpeed / 100) / 2;
|
|
const doubleHit = 1 + effects.doubleChance / 100;
|
|
const critChance = effects.critChance / 100;
|
|
const critDamage = effects.critDamage / 100;
|
|
const crit = 1 + critChance * critDamage;
|
|
const critHit = singleHit * (1 + critDamage);
|
|
const cooldown = combatStyle === COMBAT_STYLES.SKILL ? 1 / Math.max(0.05, 1 - effects.skillCooldown / 100) : 1;
|
|
return {
|
|
singleHit,
|
|
critHit,
|
|
dps: singleHit * attacksPerSecond * doubleHit * crit * cooldown,
|
|
};
|
|
}
|
|
|
|
function calculateSurvivability(totals) {
|
|
const effects = totals.effects;
|
|
const baseHealth = Math.max(0, totals.baseHealth);
|
|
const health = baseHealth * (1 + effects.health / 100);
|
|
const blockMultiplier = 1 / Math.max(0.05, 1 - effects.blockChance / 100);
|
|
const regenMultiplier = 1 + effects.healthRegen / 100;
|
|
const lifestealMultiplier = 1 + effects.lifesteal / 100;
|
|
return health * blockMultiplier * regenMultiplier * lifestealMultiplier;
|
|
}
|
|
|
|
function calculateRegenDetails(totals, dps) {
|
|
const maxHealth = Math.max(0, totals.baseHealth) * (1 + totals.effects.health / 100);
|
|
const healthRegen = maxHealth * (totals.effects.healthRegen / 100);
|
|
const lifesteal = Math.max(0, dps) * (totals.effects.lifesteal / 100);
|
|
return {
|
|
healthRegen,
|
|
lifesteal,
|
|
total: healthRegen + lifesteal,
|
|
};
|
|
}
|
|
|
|
function getStyleDamageBonus(effects, combatStyle) {
|
|
if (combatStyle === COMBAT_STYLES.RANGED) return effects.rangedDamage;
|
|
if (combatStyle === COMBAT_STYLES.SKILL) return effects.skillDamage;
|
|
return effects.meleeDamage;
|
|
}
|
|
|
|
function cloneTotalsWithEffectDelta(totals, effectType, delta) {
|
|
return {
|
|
baseDamage: totals.baseDamage,
|
|
baseHealth: totals.baseHealth,
|
|
effects: {
|
|
...totals.effects,
|
|
[effectType]: Math.max(0, (totals.effects[effectType] || 0) + delta),
|
|
},
|
|
};
|
|
}
|
|
|
|
function calculateMaxRollGain(effect, totals, currentDps, currentSurvivability) {
|
|
const withMaxRoll = cloneTotalsWithEffectDelta(totals, effect.id, effect.cap);
|
|
const dpsGain = percentChange(currentDps, calculateDps(withMaxRoll, state.combatStyle));
|
|
const survivabilityGain = percentChange(currentSurvivability, calculateSurvivability(withMaxRoll));
|
|
const parts = [];
|
|
if (Math.abs(dpsGain) >= 0.05) parts.push(`+${fmt(dpsGain)}% DPS`);
|
|
if (Math.abs(survivabilityGain) >= 0.05) parts.push(`+${fmt(survivabilityGain)}% survivability`);
|
|
return {
|
|
label: parts.length ? parts.join(" / ") : "no effect for current style",
|
|
score: Math.max(0, dpsGain, survivabilityGain),
|
|
};
|
|
}
|
|
|
|
function classifyGain(score, bestScore) {
|
|
if (bestScore <= 0 || score <= 0.05) return "gain-low";
|
|
const ratio = score / bestScore;
|
|
if (ratio >= 0.75) return "gain-best";
|
|
if (ratio >= 0.4) return "gain-good";
|
|
if (ratio >= 0.15) return "gain-mid";
|
|
return "gain-low";
|
|
}
|
|
|
|
function percentChange(from, to) {
|
|
if (from <= 0) return to > 0 ? 100 : 0;
|
|
return ((to / from) - 1) * 100;
|
|
}
|
|
|
|
function scorePiece(piece, profile, totals) {
|
|
const baseAverageDamage = Math.max(1, totals.baseDamage / 6);
|
|
const baseAverageHealth = Math.max(1, totals.baseHealth / 6);
|
|
let score = (Number(piece.baseDamage || 0) / baseAverageDamage) * 35 * profile.baseDamage
|
|
+ (Number(piece.baseHealth || 0) / baseAverageHealth) * 35 * profile.baseHealth;
|
|
const reasons = [];
|
|
|
|
for (const effect of piece.effects) {
|
|
if (!effect.type) {
|
|
continue;
|
|
}
|
|
const def = effectById(effect.type);
|
|
const normalized = clampEffectValue(effect.type, Number(effect.value)) / def.cap;
|
|
score += normalized * 100 * profile.weights[effect.type];
|
|
if (normalized < 0.3) reasons.push(`${def.label} is still low`);
|
|
if (profile.weights[effect.type] <= 3) reasons.push(`${def.label} is low priority for this profile`);
|
|
}
|
|
|
|
if (!Number(piece.baseDamage || 0) && !Number(piece.baseHealth || 0)) reasons.push("no base stat entered");
|
|
return { piece, score, reasons: [...new Set(reasons)] };
|
|
}
|
|
|
|
function renderEffectTotals(totals, currentDps, currentSurvivability) {
|
|
const gains = EFFECTS.map((effect) => ({
|
|
effect,
|
|
gain: calculateMaxRollGain(effect, totals, currentDps, currentSurvivability),
|
|
}));
|
|
const bestScore = Math.max(...gains.map(({ gain }) => gain.score), 0);
|
|
|
|
$("#effectTotals").innerHTML = gains.map(({ effect, gain }) => `
|
|
<div class="effect-pill">
|
|
<span>${effect.label}</span>
|
|
<strong>${fmt(totals.effects[effect.id])}%</strong>
|
|
<small class="max-roll-row">
|
|
<span>Max roll +${effect.cap}%</span>
|
|
<strong class="${classifyGain(gain.score, bestScore)}">${gain.label}</strong>
|
|
</small>
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
function getDamageOptimizationEffectIds(combatStyle, optimizationTarget = OPTIMIZATION_TARGETS.DAMAGE) {
|
|
const ids = ["critChance", "critDamage", "doubleChance", "damage", "attackSpeed"];
|
|
if ([OPTIMIZATION_TARGETS.LIFESTEAL_REGEN, OPTIMIZATION_TARGETS.DAMAGE_LIFESTEAL_MIX].includes(optimizationTarget)) ids.push("lifesteal");
|
|
if (combatStyle === COMBAT_STYLES.RANGED) ids.push("rangedDamage");
|
|
else if (combatStyle === COMBAT_STYLES.SKILL) ids.push("skillDamage", "skillCooldown");
|
|
else ids.push("meleeDamage");
|
|
return ids;
|
|
}
|
|
|
|
function countActiveEffects() {
|
|
return state.pieces.reduce((count, piece) => count + (piece.effects || []).filter((effect) => effect.type).length, 0);
|
|
}
|
|
|
|
function summarizeCurrentEffects() {
|
|
const summary = Object.fromEntries(EFFECTS.map((effect) => [effect.id, { count: 0, total: 0 }]));
|
|
for (const piece of state.pieces) {
|
|
for (const effect of piece.effects || []) {
|
|
if (!effect.type) continue;
|
|
summary[effect.type].count += 1;
|
|
summary[effect.type].total += clampEffectValue(effect.type, Number(effect.value));
|
|
}
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
function createEmptyEffectTotals() {
|
|
return Object.fromEntries(EFFECTS.map((effect) => [effect.id, 0]));
|
|
}
|
|
|
|
function calculateOptimizationMetric(candidateTotals, combatStyle, optimizationTarget, metricContext = {}) {
|
|
const dps = calculateDps(candidateTotals, combatStyle);
|
|
const lifestealRegen = calculateRegenDetails(candidateTotals, dps).lifesteal;
|
|
if (optimizationTarget === OPTIMIZATION_TARGETS.LIFESTEAL_REGEN) return lifestealRegen;
|
|
if (optimizationTarget === OPTIMIZATION_TARGETS.DAMAGE_LIFESTEAL_MIX) {
|
|
const damageScore = metricContext.bestDamageDps > 0 ? dps / metricContext.bestDamageDps : 0;
|
|
const lifestealScore = metricContext.bestLifestealRegen > 0 ? lifestealRegen / metricContext.bestLifestealRegen : 0;
|
|
return (2 * damageScore) + lifestealScore;
|
|
}
|
|
return dps;
|
|
}
|
|
|
|
function getOptimizationMetricLabel(optimizationTarget) {
|
|
if (optimizationTarget === OPTIMIZATION_TARGETS.DAMAGE_LIFESTEAL_MIX) return "Optimal weighted score";
|
|
return optimizationTarget === OPTIMIZATION_TARGETS.LIFESTEAL_REGEN ? "Optimal lifesteal regen" : "Optimal DPS";
|
|
}
|
|
|
|
function findOptimalDamageSetup(totals, effectCount, combatStyle, optimizationTarget, metricContext = {}) {
|
|
const effectIds = getDamageOptimizationEffectIds(combatStyle, optimizationTarget);
|
|
const emptyTotals = { ...totals, effects: createEmptyEffectTotals() };
|
|
let best = {
|
|
metric: calculateOptimizationMetric(emptyTotals, combatStyle, optimizationTarget, metricContext),
|
|
counts: Object.fromEntries(effectIds.map((id) => [id, 0])),
|
|
};
|
|
const counts = Object.fromEntries(effectIds.map((id) => [id, 0]));
|
|
|
|
function visit(effectIndex, remaining) {
|
|
const effectId = effectIds[effectIndex];
|
|
if (effectIndex === effectIds.length - 1) {
|
|
counts[effectId] = remaining;
|
|
const effects = createEmptyEffectTotals();
|
|
for (const id of effectIds) effects[id] = counts[id] * effectById(id).cap;
|
|
const candidateTotals = { baseDamage: totals.baseDamage, baseHealth: totals.baseHealth, effects };
|
|
const metric = calculateOptimizationMetric(candidateTotals, combatStyle, optimizationTarget, metricContext);
|
|
if (metric > best.metric) best = { metric, counts: { ...counts } };
|
|
counts[effectId] = 0;
|
|
return;
|
|
}
|
|
|
|
for (let count = 0; count <= remaining; count += 1) {
|
|
counts[effectId] = count;
|
|
visit(effectIndex + 1, remaining - count);
|
|
}
|
|
counts[effectId] = 0;
|
|
}
|
|
|
|
visit(0, effectCount);
|
|
return best;
|
|
}
|
|
|
|
function renderEffectComparisonRows(optimumCounts, currentSummary) {
|
|
const rows = EFFECTS.map((effect) => {
|
|
const optimumCount = optimumCounts[effect.id] || 0;
|
|
const current = currentSummary[effect.id];
|
|
return `
|
|
<tr>
|
|
<th scope="row">${effect.label}</th>
|
|
<td>${optimumCount ? optimumCount : ""}</td>
|
|
<td>${optimumCount ? `${effect.cap}%` : ""}</td>
|
|
<td>${current.count || ""}</td>
|
|
<td>${current.count ? `${fmt(current.total / current.count)}%` : ""}</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
$("#effectComparisonRows").innerHTML = rows.join("");
|
|
}
|
|
|
|
function renderOptimalDamageSetup(totals, currentDps) {
|
|
const effectCount = countActiveEffects();
|
|
const metricContext = getOptimizationMetricContext(totals, effectCount, state.combatStyle, state.optimizationTarget);
|
|
const optimum = findOptimalDamageSetup(totals, effectCount, state.combatStyle, state.optimizationTarget, metricContext);
|
|
const currentMetric = calculateOptimizationMetric(totals, state.combatStyle, state.optimizationTarget, metricContext);
|
|
const delta = percentChange(currentMetric, optimum.metric);
|
|
const currentEffectSummary = summarizeCurrentEffects();
|
|
|
|
$("#optimumEffectCount").textContent = `${effectCount} effect${effectCount === 1 ? "" : "s"}`;
|
|
$("#optimumMetricLabel").textContent = getOptimizationMetricLabel(state.optimizationTarget);
|
|
$("#optimumDps").textContent = fmt(optimum.metric);
|
|
$("#optimumDpsDelta").textContent = formatDelta(delta, "%");
|
|
$("#optimumDpsDelta").className = deltaClass(delta);
|
|
renderEffectComparisonRows(effectCount ? optimum.counts : {}, currentEffectSummary);
|
|
}
|
|
|
|
function getOptimizationMetricContext(totals, effectCount, combatStyle, optimizationTarget) {
|
|
if (optimizationTarget !== OPTIMIZATION_TARGETS.DAMAGE_LIFESTEAL_MIX || effectCount <= 0) return {};
|
|
return {
|
|
bestDamageDps: findOptimalDamageSetup(totals, effectCount, combatStyle, OPTIMIZATION_TARGETS.DAMAGE).metric,
|
|
bestLifestealRegen: findOptimalDamageSetup(totals, effectCount, combatStyle, OPTIMIZATION_TARGETS.LIFESTEAL_REGEN).metric,
|
|
};
|
|
}
|
|
|
|
function updateEffectImpactHints(totals, currentDps) {
|
|
document.querySelectorAll(".gear-card").forEach((card, pieceIndex) => {
|
|
const piece = state.pieces[pieceIndex];
|
|
card.querySelectorAll(".effect-row").forEach((row, effectIndex) => {
|
|
const effect = piece.effects[effectIndex];
|
|
const hint = row.querySelector(".cap-hint");
|
|
if (!effect.type) {
|
|
hint.textContent = "Ignored";
|
|
return;
|
|
}
|
|
const def = effectById(effect.type);
|
|
const current = clampEffectValue(effect.type, Number(effect.value));
|
|
const withoutThis = calculateDps(cloneTotalsWithEffectDelta(totals, effect.type, -current), state.combatStyle);
|
|
const currentGain = percentChange(withoutThis, currentDps);
|
|
hint.textContent = `Actual: ${fmt(currentGain)}% DPS`;
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderSacrifices(pieceScores) {
|
|
const weakest = pieceScores
|
|
.filter(({ piece }) => !piece.noEffects)
|
|
.sort((a, b) => a.score - b.score)
|
|
.slice(0, state.recommendationCount);
|
|
$("#sacrificeList").innerHTML = weakest.map(({ piece, score, reasons }) => `
|
|
<li><strong>${escapeHtml(piece.name || piece.title)}</strong>
|
|
<small>Score ${fmt(score)}. ${reasons.length ? reasons.join("; ") : "Lowest relative contribution among entered pieces."}</small>
|
|
</li>
|
|
`).join("");
|
|
}
|
|
|
|
/* ==========================================================================
|
|
Persistence, save slots, import, and export
|
|
========================================================================== */
|
|
|
|
function saveDraft() {
|
|
localStorage.setItem(DRAFT_KEY, JSON.stringify(state));
|
|
}
|
|
|
|
function loadDraft() {
|
|
try {
|
|
const raw = localStorage.getItem(DRAFT_KEY);
|
|
return raw ? normalizeState(JSON.parse(raw)) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getSavedSlots() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function setSavedSlots(saved) {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
|
|
}
|
|
|
|
function renderSaveSlots() {
|
|
const saved = getSavedSlots();
|
|
const select = $("#slotSelect");
|
|
const names = Object.keys(saved).sort();
|
|
select.innerHTML = names.map((name) => `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`).join("") || '<option value="">No saved slots</option>';
|
|
}
|
|
|
|
function setStatus(message, danger = false) {
|
|
const node = $("#saveStatus");
|
|
node.textContent = message;
|
|
node.style.color = danger ? "var(--danger)" : "var(--good)";
|
|
}
|
|
|
|
function setImportExportStatus(message, danger = false) {
|
|
const node = $("#importExportStatus");
|
|
node.textContent = message;
|
|
node.style.color = danger ? "var(--danger)" : "var(--good)";
|
|
}
|
|
|
|
function createExportPayload() {
|
|
return {
|
|
app: "forge-master-strategy-optimizer",
|
|
version: EXPORT_VERSION,
|
|
exportedAt: new Date().toISOString(),
|
|
build: normalizeState(state),
|
|
};
|
|
}
|
|
|
|
function createExportJson() {
|
|
return JSON.stringify(createExportPayload(), null, 2);
|
|
}
|
|
|
|
async function generateExportJson() {
|
|
const json = createExportJson();
|
|
const textarea = $("#jsonTransfer");
|
|
textarea.value = json;
|
|
textarea.focus();
|
|
textarea.select();
|
|
try {
|
|
await navigator.clipboard.writeText(json);
|
|
setImportExportStatus("Export JSON generated and copied to clipboard.");
|
|
} catch {
|
|
setImportExportStatus("Export JSON generated. Copy it from the text box or download it as a file.");
|
|
}
|
|
}
|
|
|
|
function downloadExportJson() {
|
|
const json = createExportJson();
|
|
$("#jsonTransfer").value = json;
|
|
const blob = new Blob([json], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `forge-master-build-${new Date().toISOString().slice(0, 10)}.json`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(url);
|
|
setImportExportStatus("Downloaded current build as JSON.");
|
|
}
|
|
|
|
function parseImportedState(jsonText) {
|
|
if (!jsonText.trim()) throw new Error("Paste JSON first or upload a JSON file.");
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(jsonText);
|
|
} catch {
|
|
throw new Error("Invalid JSON. Check that the full export was copied.");
|
|
}
|
|
const build = parsed?.build || parsed;
|
|
if (!build || typeof build !== "object" || !Array.isArray(build.pieces)) {
|
|
throw new Error("JSON does not look like a Forge Master build export.");
|
|
}
|
|
return normalizeState(build);
|
|
}
|
|
|
|
function importJsonText(jsonText, sourceLabel = "JSON") {
|
|
try {
|
|
state = parseImportedState(jsonText);
|
|
render();
|
|
setImportExportStatus(`Imported ${sourceLabel}. Draft autosaved.`);
|
|
} catch (error) {
|
|
setImportExportStatus(error.message, true);
|
|
}
|
|
}
|
|
|
|
function importPastedJson() {
|
|
importJsonText($("#jsonTransfer").value, "pasted JSON");
|
|
}
|
|
|
|
function importUploadedJson(event) {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.addEventListener("load", () => {
|
|
const text = String(reader.result || "");
|
|
$("#jsonTransfer").value = text;
|
|
importJsonText(text, file.name);
|
|
event.target.value = "";
|
|
});
|
|
reader.addEventListener("error", () => {
|
|
setImportExportStatus("Could not read the selected file.", true);
|
|
event.target.value = "";
|
|
});
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
/* ==========================================================================
|
|
Event handling
|
|
========================================================================== */
|
|
|
|
function bindEvents() {
|
|
$("#profileSelect").addEventListener("change", (event) => {
|
|
state.profile = event.target.value;
|
|
updateAnalysis();
|
|
});
|
|
$("#combatStyle").addEventListener("change", (event) => {
|
|
state.combatStyle = event.target.value;
|
|
updateAnalysis();
|
|
});
|
|
$("#optimizationTarget").addEventListener("change", (event) => {
|
|
state.optimizationTarget = event.target.value;
|
|
updateAnalysis();
|
|
});
|
|
$("#recommendationCount").addEventListener("change", (event) => {
|
|
state.recommendationCount = Math.min(20, Math.max(3, Math.round(parseNumberInput(event.target.value, 8))));
|
|
formatChangedInput(event, state.recommendationCount);
|
|
updateAnalysis();
|
|
});
|
|
$("#resetBuild").addEventListener("click", () => {
|
|
state = createDefaultState();
|
|
render();
|
|
setStatus("Current build reset. Draft autosaved.");
|
|
});
|
|
$("#gearGrid").addEventListener("change", handleGearInput);
|
|
$("#saveSlot").addEventListener("click", saveNamedSlot);
|
|
$("#loadSlot").addEventListener("click", loadNamedSlot);
|
|
$("#deleteSlot").addEventListener("click", deleteNamedSlot);
|
|
$("#copyExport").addEventListener("click", generateExportJson);
|
|
$("#downloadExport").addEventListener("click", downloadExportJson);
|
|
$("#importJson").addEventListener("click", importPastedJson);
|
|
$("#importFile").addEventListener("change", importUploadedJson);
|
|
$("#newItemTarget").addEventListener("change", (event) => {
|
|
state.newItem.target = event.target.value;
|
|
state.newItem.targetPieceId = null;
|
|
renderNewItemComparison();
|
|
updateAnalysis();
|
|
});
|
|
$("#swapNewItem").addEventListener("click", swapInNewItem);
|
|
$(".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, parseNumberInput(event.target.value, 0));
|
|
formatChangedInput(event, state.newItem[event.target.dataset.newItemField]);
|
|
}
|
|
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, parseNumberInput(event.target.value, 1));
|
|
formatChangedInput(event, state.newItem.effects[index].value);
|
|
}
|
|
updateAnalysis();
|
|
}
|
|
|
|
function handleGearInput(event) {
|
|
const card = event.target.closest(".gear-card");
|
|
if (!card) return;
|
|
const piece = state.pieces[Number(card.dataset.index)];
|
|
|
|
if (event.target.dataset.field) {
|
|
if (piece.fixedBase && ["name", "baseType"].includes(event.target.dataset.field)) {
|
|
event.target.value = piece[event.target.dataset.field];
|
|
return;
|
|
}
|
|
const field = event.target.dataset.field;
|
|
piece[field] = ["baseValue", "baseDamage", "baseHealth"].includes(field) ? Math.max(0, parseNumberInput(event.target.value, 0)) : event.target.value;
|
|
if (field === "baseValue") {
|
|
if (piece.baseType === "health") piece.baseHealth = piece.baseValue;
|
|
else piece.baseDamage = piece.baseValue;
|
|
}
|
|
if (field === "baseDamage" || field === "baseHealth") {
|
|
piece.baseType = Number(piece.baseHealth || 0) > Number(piece.baseDamage || 0) ? "health" : "damage";
|
|
piece.baseValue = Math.max(Number(piece.baseDamage || 0), Number(piece.baseHealth || 0));
|
|
}
|
|
if (["baseValue", "baseDamage", "baseHealth"].includes(field)) formatChangedInput(event, piece[field]);
|
|
}
|
|
if (event.target.dataset.effectType !== undefined) {
|
|
const index = Number(event.target.dataset.effectType);
|
|
piece.effects[index].type = event.target.value;
|
|
piece.effects[index].value = clampEffectValue(event.target.value, piece.effects[index].value);
|
|
renderGear();
|
|
}
|
|
if (event.target.dataset.effectValue !== undefined) {
|
|
const index = Number(event.target.dataset.effectValue);
|
|
piece.effects[index].value = clampEffectValue(piece.effects[index].type, parseNumberInput(event.target.value, 1));
|
|
formatChangedInput(event, piece.effects[index].value);
|
|
}
|
|
updateAnalysis();
|
|
}
|
|
|
|
function formatChangedInput(event, value) {
|
|
if (event.type === "change") event.target.value = fmt(value);
|
|
}
|
|
|
|
function saveNamedSlot() {
|
|
const name = $("#slotName").value.trim();
|
|
if (!name) return setStatus("Enter a slot name first.", true);
|
|
const saved = getSavedSlots();
|
|
saved[name] = { ...state, savedAt: new Date().toISOString() };
|
|
setSavedSlots(saved);
|
|
renderSaveSlots();
|
|
$("#slotSelect").value = name;
|
|
setStatus(`Saved slot “${name}”.`);
|
|
}
|
|
|
|
function loadNamedSlot() {
|
|
const name = $("#slotSelect").value;
|
|
const saved = getSavedSlots();
|
|
if (!name || !saved[name]) return setStatus("Choose a saved slot to load.", true);
|
|
state = normalizeState(saved[name]);
|
|
render();
|
|
setStatus(`Loaded slot “${name}”.`);
|
|
}
|
|
|
|
function deleteNamedSlot() {
|
|
const name = $("#slotSelect").value;
|
|
const saved = getSavedSlots();
|
|
if (!name || !saved[name]) return setStatus("Choose a saved slot to delete.", true);
|
|
delete saved[name];
|
|
setSavedSlots(saved);
|
|
renderSaveSlots();
|
|
setStatus(`Deleted slot “${name}”.`);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value).replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char]));
|
|
}
|
|
|
|
/* ==========================================================================
|
|
Bootstrap
|
|
========================================================================== */
|
|
|
|
bindEvents();
|
|
render();
|