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 = {
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,
},
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;
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: "item", title: `Item ${i + 1}`, fixed: item })),
{ type: "mount", title: "Mount" },
...Array.from({ length: 3 }, (_, i) => ({ type: "pet", title: `Pet ${i + 1}` })),
{ type: "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();
function createDefaultState() {
return {
profile: "lifesteal",
combatStyle: "melee",
recommendationCount: 8,
pieces: slots.map((slot, index) => createDefaultPiece(slot, index)),
};
}
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 !== "item") return piece;
return {
...piece,
name: fixed.name,
baseType: fixed.baseType,
baseValue: fixed.baseValue,
baseDamage: fixed.baseType === "damage" ? fixed.baseValue : 0,
baseHealth: fixed.baseType === "health" ? fixed.baseValue : 0,
fixedBase: true,
};
}
function normalizeState(candidate) {
const fallback = createDefaultState();
const profile = normalizeProfile(candidate?.profile);
return {
...fallback,
...candidate,
profile,
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) => enforceFixedPieceFields({
...piece,
...(candidate?.pieces?.[index] || {}),
baseDamage: Number(candidate?.pieces?.[index]?.baseDamage ?? (candidate?.pieces?.[index]?.baseType === "damage" ? candidate?.pieces?.[index]?.baseValue : piece.baseDamage) ?? 0),
baseHealth: Number(candidate?.pieces?.[index]?.baseHealth ?? (candidate?.pieces?.[index]?.baseType === "health" ? candidate?.pieces?.[index]?.baseValue : piece.baseHealth) ?? 0),
noEffects: piece.noEffects || Boolean(candidate?.pieces?.[index]?.noEffects),
effects: piece.noEffects ? [] : [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),
),
})),
}, index)),
};
}
function normalizeProfile(profile) {
if (PROFILES[profile]) return profile;
if (["damage", "skill", "balanced"].includes(profile)) return "lifesteal";
if (profile === "survival") return "regen";
return "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));
}
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.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) => {
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 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);
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) {
totals.baseDamage += Number(piece.baseDamage || 0);
totals.baseHealth += Number(piece.baseHealth || 0);
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") {
return calculateDamageDetails(totals, combatStyle).dps;
}
function calculateDamageDetails(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 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 === "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 === "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 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 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 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 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("");
}
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);
}
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);
}
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]));
}
bindEvents();
render();