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 PROFILES = { balanced: { label: "Balanced", weights: { critChance: 8, critDamage: 4, blockChance: 7, healthRegen: 7, lifesteal: 7, doubleChance: 8, damage: 9, meleeDamage: 3, rangedDamage: 5, attackSpeed: 6, skillDamage: 5, skillCooldown: 8, health: 7 }, baseDamage: 1, baseHealth: 0.75, }, damage: { label: "Damage focused", weights: { critChance: 10, critDamage: 6, blockChance: 3, healthRegen: 3, lifesteal: 6, doubleChance: 10, damage: 10, meleeDamage: 5, rangedDamage: 7, attackSpeed: 8, skillDamage: 7, skillCooldown: 7, health: 3 }, baseDamage: 1.25, baseHealth: 0.45, }, survival: { label: "Survivability focused", weights: { critChance: 5, critDamage: 2, blockChance: 10, healthRegen: 10, lifesteal: 9, doubleChance: 5, damage: 5, meleeDamage: 2, rangedDamage: 3, attackSpeed: 4, skillDamage: 3, skillCooldown: 5, health: 10 }, baseDamage: 0.65, baseHealth: 1.2, }, skill: { label: "Skill focused", weights: { critChance: 7, critDamage: 4, blockChance: 5, healthRegen: 5, lifesteal: 6, doubleChance: 7, damage: 7, meleeDamage: 2, rangedDamage: 4, attackSpeed: 5, skillDamage: 10, skillCooldown: 11, health: 5 }, baseDamage: 0.9, baseHealth: 0.65, }, }; const STORAGE_KEY = "forgeMasterOptimizer.v1"; const DRAFT_KEY = "forgeMasterOptimizer.draft.v1"; const slots = [ ...Array.from({ length: 8 }, (_, i) => ({ type: "item", title: `Item ${i + 1}` })), { type: "mount", title: "Mount" }, ...Array.from({ length: 3 }, (_, i) => ({ type: "pet", title: `Pet ${i + 1}` })), ]; const $ = (selector) => document.querySelector(selector); const fmt = (value) => Number(value || 0).toLocaleString(undefined, { maximumFractionDigits: 1 }); const effectById = (id) => EFFECTS.find((effect) => effect.id === id); let state = loadDraft() || createDefaultState(); function createDefaultState() { return { profile: "balanced", combatStyle: "melee", recommendationCount: 8, pieces: slots.map((slot, index) => ({ id: `${slot.type}-${index}`, type: slot.type, title: slot.title, name: slot.title, baseType: index % 3 === 1 ? "health" : "damage", baseValue: 0, effects: [{ type: "", value: 1 }, { type: "", value: 1 }], })), }; } function normalizeState(candidate) { const fallback = createDefaultState(); return { ...fallback, ...candidate, profile: PROFILES[candidate?.profile] ? candidate.profile : "balanced", combatStyle: ["melee", "ranged", "skill"].includes(candidate?.combatStyle) ? candidate.combatStyle : "melee", recommendationCount: Math.min(20, Math.max(3, Number(candidate?.recommendationCount || 8))), pieces: fallback.pieces.map((piece, index) => ({ ...piece, ...(candidate?.pieces?.[index] || {}), effects: [0, 1].map((effectIndex) => ({ type: candidate?.pieces?.[index]?.effects?.[effectIndex]?.type || "", value: clampEffectValue( candidate?.pieces?.[index]?.effects?.[effectIndex]?.type || "", Number(candidate?.pieces?.[index]?.effects?.[effectIndex]?.value || 1), ), })), })), }; } 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)); } function render() { renderControls(); renderGear(); renderSaveSlots(); updateAnalysis(); } function renderControls() { $("#profileSelect").innerHTML = Object.entries(PROFILES).map(([id, profile]) => ``).join(""); $("#profileSelect").value = state.profile; $("#combatStyle").value = state.combatStyle; $("#recommendationCount").value = state.recommendationCount; } 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.title; node.querySelector('[data-field="name"]').value = piece.name; node.querySelector('[data-field="baseType"]').value = piece.baseType; node.querySelector('[data-field="baseValue"]').value = piece.baseValue; const effects = node.querySelector(".effects"); piece.effects.forEach((effect, effectIndex) => { const row = document.createElement("div"); row.className = "effect-row"; row.innerHTML = ` `; row.querySelector("select").value = effect.type; const hint = row.querySelector(".cap-hint"); hint.textContent = effect.type ? `Cap: ${effectById(effect.type).cap}%` : "Ignored"; effects.appendChild(row); }); grid.appendChild(node); }); } function updateAnalysis() { const profile = PROFILES[state.profile]; const totals = calculateTotals(); const dps = calculateDps(totals, state.combatStyle); const pieceScores = state.pieces.map((piece) => scorePiece(piece, profile, totals)); $("#totalDamage").textContent = fmt(totals.baseDamage); $("#totalHealth").textContent = fmt(totals.baseHealth); $("#dpsValue").textContent = fmt(dps); $("#defenseScore").textContent = fmt(calculateGroupScore(totals, profile, ["defense", "hybrid"], "health")); document.querySelectorAll(".gear-card").forEach((card, index) => { card.querySelector(".piece-score").textContent = `Score ${fmt(pieceScores[index].score)}`; }); updateEffectImpactHints(totals, dps); renderEffectTotals(totals.effects); renderUpgrades(profile, totals, dps); renderSacrifices(pieceScores); saveDraft(); } function calculateTotals() { const totals = { baseDamage: 0, baseHealth: 0, effects: Object.fromEntries(EFFECTS.map((effect) => [effect.id, 0])) }; for (const piece of state.pieces) { const baseValue = Number(piece.baseValue || 0); if (piece.baseType === "health") totals.baseHealth += baseValue; else totals.baseDamage += baseValue; for (const effect of piece.effects) { if (effect.type) totals.effects[effect.type] += clampEffectValue(effect.type, Number(effect.value)); } } return totals; } function calculateDps(totals, combatStyle = "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 attackSpeed = 1 + effects.attackSpeed / 100; const doubleHit = 1 + effects.doubleChance / 100; const critChance = effects.critChance / 100; const critDamage = effects.critDamage / 100; const crit = 1 + critChance * critDamage; const cooldown = combatStyle === "skill" ? 1 / Math.max(0.05, 1 - effects.skillCooldown / 100) : 1; return baseDamage * globalDamage * styleDamage * attackSpeed * doubleHit * crit * cooldown; } function getStyleDamageBonus(effects, combatStyle) { if (combatStyle === "ranged") return effects.rangedDamage; if (combatStyle === "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 percentChange(from, to) { if (from <= 0) return to > 0 ? 100 : 0; return ((to / from) - 1) * 100; } function calculateGroupScore(totals, profile, groups, baseKind) { const effectScore = EFFECTS.filter((effect) => groups.includes(effect.group)) .reduce((sum, effect) => sum + (totals.effects[effect.id] / effect.cap) * 100 * profile.weights[effect.id], 0); const baseScore = baseKind === "health" ? totals.baseHealth * profile.baseHealth : totals.baseDamage * profile.baseDamage; return effectScore + baseScore; } function scorePiece(piece, profile, totals) { const baseWeight = piece.baseType === "health" ? profile.baseHealth : profile.baseDamage; const baseAverage = Math.max(1, piece.baseType === "health" ? totals.baseHealth / 6 : totals.baseDamage / 6); let score = (Number(piece.baseValue || 0) / baseAverage) * 35 * baseWeight; const reasons = []; for (const effect of piece.effects) { if (!effect.type) { reasons.push("missing effect slot"); 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.baseValue || 0)) reasons.push("no base stat entered"); return { piece, score, reasons: [...new Set(reasons)] }; } function renderEffectTotals(effectTotals) { $("#effectTotals").innerHTML = EFFECTS.map((effect) => `
${effect.label}${fmt(effectTotals[effect.id])}%
`).join(""); } 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); const remaining = Math.max(0, def.cap - current); const plusOne = Math.min(1, remaining); const plusOneGain = plusOne > 0 ? percentChange(currentDps, calculateDps(cloneTotalsWithEffectDelta(totals, effect.type, plusOne), state.combatStyle)) : 0; hint.textContent = `Cap: ${def.cap}%. Actual now: ${fmt(currentGain)}% DPS. Next +${fmt(plusOne)}%: ${fmt(plusOneGain)}% DPS.`; }); }); } function renderUpgrades(profile, totals, currentDps) { const upgrades = []; for (const piece of state.pieces) { for (const effect of piece.effects) { if (!effect.type) continue; const def = effectById(effect.type); const current = clampEffectValue(effect.type, Number(effect.value)); const remaining = def.cap - current; if (remaining <= 0) continue; const plusOne = Math.min(1, remaining); const onePointDps = calculateDps(cloneTotalsWithEffectDelta(totals, effect.type, plusOne), state.combatStyle); const capDps = calculateDps(cloneTotalsWithEffectDelta(totals, effect.type, remaining), state.combatStyle); const onePointGain = percentChange(currentDps, onePointDps); const capGain = percentChange(currentDps, capDps); const fallback = profile.weights[effect.type] * 0.001; const efficiency = capGain || onePointGain || fallback; upgrades.push({ piece, def, current, remaining, efficiency, onePointGain, capGain }); } } upgrades.sort((a, b) => b.efficiency - a.efficiency); const count = state.recommendationCount; $("#upgradeList").innerHTML = upgrades.slice(0, count).map((item) => `
  • ${item.def.label} on ${escapeHtml(item.piece.name || item.piece.title)} ${fmt(item.current)}% now, ${fmt(item.remaining)}% room to cap. Next +1% gives ${fmt(item.onePointGain)}% DPS; to cap gives ${fmt(item.capGain)}% DPS.
  • `).join("") || "
  • No upgradeable effects entered yet.
  • "; } function renderSacrifices(pieceScores) { const weakest = [...pieceScores].sort((a, b) => a.score - b.score).slice(0, state.recommendationCount); $("#sacrificeList").innerHTML = weakest.map(({ piece, score, reasons }) => `
  • ${escapeHtml(piece.name || piece.title)} Score ${fmt(score)}. ${reasons.length ? reasons.join("; ") : "Lowest relative contribution among entered pieces."}
  • `).join(""); } 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) => ``).join("") || ''; } function setStatus(message, danger = false) { const node = $("#saveStatus"); node.textContent = message; node.style.color = danger ? "var(--danger)" : "var(--good)"; } function bindEvents() { $("#profileSelect").addEventListener("change", (event) => { state.profile = event.target.value; updateAnalysis(); }); $("#combatStyle").addEventListener("change", (event) => { state.combatStyle = event.target.value; updateAnalysis(); }); $("#recommendationCount").addEventListener("input", (event) => { state.recommendationCount = Math.min(20, Math.max(3, Number(event.target.value || 8))); updateAnalysis(); }); $("#resetBuild").addEventListener("click", () => { state = createDefaultState(); render(); setStatus("Current build reset. Draft autosaved."); }); $("#gearGrid").addEventListener("input", handleGearInput); $("#gearGrid").addEventListener("change", handleGearInput); $("#saveSlot").addEventListener("click", saveNamedSlot); $("#loadSlot").addEventListener("click", loadNamedSlot); $("#deleteSlot").addEventListener("click", deleteNamedSlot); } 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) { const field = event.target.dataset.field; piece[field] = field === "baseValue" ? Math.max(0, Number(event.target.value || 0)) : event.target.value; } 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, Number(event.target.value || 1)); } updateAnalysis(); } 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])); } bindEvents(); render();