/* ========================================================================== 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 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 fmt = (value) => Number(value || 0).toLocaleString(undefined, { maximumFractionDigits: 1 }); 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, 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, 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]) => ``).join(""); $("#profileSelect").value = state.profile; $("#combatStyle").value = state.combatStyle; $("#recommendationCount").value = state.recommendationCount; } function createEffectRow(effect, effectIndex, typeAttribute, valueAttribute, showInitialHint = true) { const row = document.createElement("div"); row.className = "effect-row"; row.innerHTML = ` `; 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 = ` `; baseFields.querySelector('[data-field="baseType"]').value = piece.baseType; baseFields.querySelector('[data-field="baseValue"]').value = piece.baseValue; } else { baseFields.innerHTML = ` `; baseFields.querySelector('[data-field="baseDamage"]').value = piece.baseDamage || 0; baseFields.querySelector('[data-field="baseHealth"]').value = piece.baseHealth || 0; } const effects = node.querySelector(".effects"); if (piece.noEffects) { effects.innerHTML = '

Base stats only. No effects.

'; } 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) => ``).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 = ` `; } else { baseFields.innerHTML = ` `; } 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)}
Crit ${fmt(damageDetails.critHit)}`; $("#regenValue").textContent = fmt(regenDetails.total); $("#regenDetails").innerHTML = `Health regen ${fmt(regenDetails.healthRegen)}
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 }) => `
${effect.label} ${fmt(totals.effects[effect.id])}% Max roll +${effect.cap}% ${gain.label}
`).join(""); } function getDamageOptimizationEffectIds(combatStyle) { const ids = ["critChance", "critDamage", "doubleChance", "damage", "attackSpeed"]; 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 createEmptyEffectTotals() { return Object.fromEntries(EFFECTS.map((effect) => [effect.id, 0])); } function findOptimalDamageSetup(totals, effectCount, combatStyle) { const effectIds = getDamageOptimizationEffectIds(combatStyle); let best = { dps: calculateDps({ ...totals, effects: createEmptyEffectTotals() }, combatStyle), 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 dps = calculateDps({ baseDamage: totals.baseDamage, baseHealth: totals.baseHealth, effects }, combatStyle); if (dps > best.dps) best = { dps, 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 formatOptimalSuggestion(counts) { return Object.entries(counts) .filter(([, count]) => count > 0) .map(([effectId, count]) => { const effect = effectById(effectId); return `${count}x ${effect.cap}% ${effect.label.toLowerCase()}`; }) .join(", "); } function renderOptimalDamageSetup(totals, currentDps) { const effectCount = countActiveEffects(); const optimum = findOptimalDamageSetup(totals, effectCount, state.combatStyle); const delta = percentChange(currentDps, optimum.dps); $("#optimumEffectCount").textContent = `${effectCount} effect${effectCount === 1 ? "" : "s"}`; $("#optimumDps").textContent = fmt(optimum.dps); $("#optimumDpsDelta").textContent = formatDelta(delta, "%"); $("#optimumDpsDelta").className = deltaClass(delta); $("#optimumSuggestion").textContent = effectCount ? formatOptimalSuggestion(optimum.counts) : "Add effects to your current build to see an optimized max-roll damage mix."; } 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 }) => `
  • ${escapeHtml(piece.name || piece.title)} Score ${fmt(score)}. ${reasons.length ? reasons.join("; ") : "Lowest relative contribution among entered pieces."}
  • `).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) => ``).join("") || ''; } 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(); }); $("#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); $("#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("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) { 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", "baseValue"].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, Number(event.target.value || 0)) : event.target.value; 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 (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])); } /* ========================================================================== Bootstrap ========================================================================== */ bindEvents(); render();