From d4f3d6609b4af63cd7a5f1765d3c045d840024e0 Mon Sep 17 00:00:00 2001 From: Jan Bader Date: Tue, 16 Jun 2026 14:45:10 +0200 Subject: [PATCH] Add Forge Master strategy optimizer --- README.md | 105 +++++++++++++++ app.js | 368 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 103 +++++++++++++++ styles.css | 162 +++++++++++++++++++++++ 4 files changed, 738 insertions(+) create mode 100644 README.md create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..e72c308 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Forge Master Strategy Optimizer + +A small browser-only webpage for planning Forge Master gear upgrades and sacrifice decisions. + +Forge Master characters can equip: + +- 8 items +- 1 mount +- 3 pets + +Each equipped piece has: + +- A base stat: `damage` or `health` +- A base stat value +- 0, 1, or 2 effects depending on rarity + +Effects start at `1%` and can be upgraded up to their cap. + +## Supported effects + +| Effect | Maximum | +| --- | ---: | +| Crit chance | 12% | +| Crit damage | 80% | +| Block chance | 5% | +| Health regen | 4% | +| Lifesteal | 20% | +| Double chance | 20% | +| Damage | 15% | +| Melee damage | 50% | +| Ranged damage | 15% | +| Attack speed | 40% | +| Skill damage | 30% | +| Skill cooldown reduction | 7% | +| Health | 15% | + +## Optimizer goals + +The app lets you enter the current state of all equipment and then recommends: + +1. **What to increase next** + - Prioritizes effects by weighted strategic value. + - Accounts for each effect's remaining room to grow before cap. + - Highlights high-value underleveled effects. + +2. **What is best to sacrifice instead** + - Scores every equipped piece from its base stat and effects. + - Identifies the lowest-value pieces as likely sacrifice candidates. + - Explains why a piece is weak, such as missing effects, low base stat, or low-impact effects. + +3. **Overall build summary** + - Totals base damage and health. + - Totals effect percentages across all items, mount, and pets. + - Shows a simple offensive and defensive score to compare save slots. + +## Strategy profile + +The first version includes an adjustable strategy profile: + +- Balanced +- Damage focused +- Survivability focused +- Skill focused + +Each profile changes effect weights used for recommendations. These weights are intentionally transparent in the code so they can be tuned as better Forge Master formulas become known. + +## Save slots + +All data is saved in `localStorage` only. No server is used. + +Features: + +- Multiple named save slots +- Save current build to a slot +- Load an existing slot +- Delete a slot +- Automatic draft saving for the current unsaved work + +## Running locally + +Open `index.html` in a browser. + +No build step is required. + +If serving from a local web server is preferred: + +```bash +python3 -m http.server 8080 +``` + +Then open: + +```text +http://localhost:8080 +``` + +## Implementation notes + +This project is intentionally dependency-free: + +- `index.html` contains the page structure. +- `styles.css` contains responsive styling. +- `app.js` contains state management, localStorage persistence, scoring, and recommendations. + +The optimizer is heuristic, not a perfect simulator. It is designed to be useful immediately and easy to tune once exact Forge Master combat formulas are known. diff --git a/app.js b/app.js new file mode 100644 index 0000000..a33d0ef --- /dev/null +++ b/app.js @@ -0,0 +1,368 @@ +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", + 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", + 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; + $("#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 pieceScores = state.pieces.map((piece) => scorePiece(piece, profile, totals)); + + $("#totalDamage").textContent = fmt(totals.baseDamage); + $("#totalHealth").textContent = fmt(totals.baseHealth); + $("#offenseScore").textContent = fmt(calculateGroupScore(totals, profile, ["offense", "skill", "hybrid"], "damage")); + $("#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)}`; + }); + + renderEffectTotals(totals.effects); + renderUpgrades(profile); + 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 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 renderUpgrades(profile) { + 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 efficiency = profile.weights[effect.type] * (1 + remaining / def.cap); + upgrades.push({ piece, def, current, remaining, efficiency }); + } + } + + 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. Priority score ${fmt(item.efficiency)}. +
  • + `).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(); + }); + $("#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(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..b8f5148 --- /dev/null +++ b/index.html @@ -0,0 +1,103 @@ + + + + + + Forge Master Strategy Optimizer + + + +
    +
    +

    Forge Master

    +

    Strategy Optimizer

    +

    Enter your items, mount, and pets to find the best upgrades and weakest sacrifice candidates.

    +
    +
    +

    Save slots

    +
    + + + + + +
    +

    +
    +
    + +
    +
    + + + +
    + +
    +
    Total damage base0
    +
    Total health base0
    +
    Offense score0
    +
    Defense score0
    +
    + +
    +
    +

    Best upgrades

    +
      +
      +
      +

      Best sacrifice candidates

      +
        +
        +
        + +
        +

        Effect totals

        +
        +
        + +
        +
        +

        Equipment

        +

        Each piece can have zero, one, or two effects. Empty effects are ignored.

        +
        +
        +
        +
        + + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..d3430a2 --- /dev/null +++ b/styles.css @@ -0,0 +1,162 @@ +:root { + color-scheme: dark; + --bg: #0b1020; + --panel: #151b2f; + --panel-2: #1c2440; + --text: #eef2ff; + --muted: #aab4d6; + --accent: #7dd3fc; + --accent-2: #a78bfa; + --danger: #fb7185; + --good: #86efac; + --border: rgba(255, 255, 255, 0.12); + --shadow: 0 20px 50px rgba(0, 0, 0, 0.35); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(125, 211, 252, 0.18), transparent 32rem), + radial-gradient(circle at top right, rgba(167, 139, 250, 0.18), transparent 28rem), + var(--bg); +} + +button, input, select { + font: inherit; +} + +button, input, select { + border: 1px solid var(--border); + border-radius: 0.75rem; + color: var(--text); + background: rgba(255, 255, 255, 0.06); + padding: 0.7rem 0.85rem; +} + +button { + cursor: pointer; + border-color: rgba(125, 211, 252, 0.45); + background: linear-gradient(135deg, rgba(14, 165, 233, 0.9), rgba(124, 58, 237, 0.9)); + font-weight: 700; +} + +button:hover { filter: brightness(1.1); } +button.secondary { background: var(--panel-2); } +button.danger { border-color: rgba(251, 113, 133, 0.5); background: rgba(251, 113, 133, 0.16); } + +label { + display: grid; + gap: 0.35rem; + color: var(--muted); + font-size: 0.9rem; +} + +main, .hero { + width: min(1440px, calc(100% - 2rem)); + margin: 0 auto; +} + +.hero { + display: grid; + grid-template-columns: 1fr minmax(320px, 520px); + gap: 1.5rem; + align-items: end; + padding: 3rem 0 1.25rem; +} + +.eyebrow { + color: var(--accent); + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: 0.5rem; font-size: clamp(2.25rem, 7vw, 5rem); line-height: 0.9; } +h2 { margin-bottom: 1rem; } +h3 { margin-bottom: 0; } +.lede { max-width: 52rem; color: var(--muted); font-size: 1.15rem; } + +.card, .save-panel, .metric { + border: 1px solid var(--border); + border-radius: 1.25rem; + background: rgba(21, 27, 47, 0.82); + box-shadow: var(--shadow); + backdrop-filter: blur(12px); +} + +.card, .save-panel { padding: 1rem; } +.save-grid { display: grid; grid-template-columns: 1fr auto; gap: 0.6rem; } +.save-grid select, .save-grid .danger { grid-column: span 1; } +.status { min-height: 1.2rem; margin: 0.75rem 0 0; color: var(--good); } + +.controls { + display: flex; + gap: 1rem; + align-items: end; + flex-wrap: wrap; + margin-bottom: 1rem; +} +.controls label { min-width: 220px; } + +.summary-grid, .results, .gear-grid { + display: grid; + gap: 1rem; +} + +.summary-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 1rem; +} + +.metric { padding: 1rem; } +.metric span { display: block; color: var(--muted); margin-bottom: 0.35rem; } +.metric strong { font-size: 1.8rem; } + +.results { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 1rem; } +.recommendation-list { display: grid; gap: 0.75rem; padding-left: 1.5rem; } +.recommendation-list li { padding-left: 0.25rem; } +.recommendation-list strong { color: var(--accent); } +.recommendation-list small { display: block; color: var(--muted); margin-top: 0.2rem; } + +.effect-totals { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 0.75rem; +} +.effect-pill { + display: flex; + justify-content: space-between; + gap: 1rem; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.55rem 0.8rem; + background: rgba(255, 255, 255, 0.05); +} + +.section-heading { margin: 1.5rem 0 1rem; } +.section-heading p { color: var(--muted); } +.gear-grid { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); padding-bottom: 3rem; } +.gear-card { display: grid; gap: 0.85rem; } +.gear-title-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.piece-score { color: var(--accent); font-weight: 800; } +.inline-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } +.effects { display: grid; gap: 0.75rem; } +.effect-row { display: grid; grid-template-columns: 1fr 110px; gap: 0.75rem; align-items: end; } +.cap-hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.2rem; } + +@media (max-width: 900px) { + .hero, .results { grid-template-columns: 1fr; } + .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 560px) { + main, .hero { width: min(100% - 1rem, 1440px); } + .summary-grid, .inline-fields, .effect-row { grid-template-columns: 1fr; } + .save-grid { grid-template-columns: 1fr; } +}