Add Forge Master strategy optimizer
This commit is contained in:
@@ -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.
|
||||||
@@ -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]) => `<option value="${id}">${profile.label}</option>`).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 = `
|
||||||
|
<label>Effect ${effectIndex + 1}
|
||||||
|
<select data-effect-type="${effectIndex}">
|
||||||
|
<option value="">None</option>
|
||||||
|
${EFFECTS.map((option) => `<option value="${option.id}">${option.label} (max ${option.cap}%)</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Percent
|
||||||
|
<input data-effect-value="${effectIndex}" type="number" min="1" step="0.1" value="${effect.value}" />
|
||||||
|
<span class="cap-hint"></span>
|
||||||
|
</label>`;
|
||||||
|
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) => `
|
||||||
|
<div class="effect-pill"><span>${effect.label}</span><strong>${fmt(effectTotals[effect.id])}%</strong></div>
|
||||||
|
`).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) => `
|
||||||
|
<li><strong>${item.def.label}</strong> on ${escapeHtml(item.piece.name || item.piece.title)}
|
||||||
|
<small>${fmt(item.current)}% now, ${fmt(item.remaining)}% room to cap. Priority score ${fmt(item.efficiency)}.</small>
|
||||||
|
</li>
|
||||||
|
`).join("") || "<li>No upgradeable effects entered yet.</li>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSacrifices(pieceScores) {
|
||||||
|
const weakest = [...pieceScores].sort((a, b) => a.score - b.score).slice(0, state.recommendationCount);
|
||||||
|
$("#sacrificeList").innerHTML = weakest.map(({ piece, score, reasons }) => `
|
||||||
|
<li><strong>${escapeHtml(piece.name || piece.title)}</strong>
|
||||||
|
<small>Score ${fmt(score)}. ${reasons.length ? reasons.join("; ") : "Lowest relative contribution among entered pieces."}</small>
|
||||||
|
</li>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft() {
|
||||||
|
localStorage.setItem(DRAFT_KEY, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDraft() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(DRAFT_KEY);
|
||||||
|
return raw ? normalizeState(JSON.parse(raw)) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSavedSlots() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSavedSlots(saved) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSaveSlots() {
|
||||||
|
const saved = getSavedSlots();
|
||||||
|
const select = $("#slotSelect");
|
||||||
|
const names = Object.keys(saved).sort();
|
||||||
|
select.innerHTML = names.map((name) => `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`).join("") || '<option value="">No saved slots</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message, danger = false) {
|
||||||
|
const node = $("#saveStatus");
|
||||||
|
node.textContent = message;
|
||||||
|
node.style.color = danger ? "var(--danger)" : "var(--good)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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();
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Forge Master Strategy Optimizer</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Forge Master</p>
|
||||||
|
<h1>Strategy Optimizer</h1>
|
||||||
|
<p class="lede">Enter your items, mount, and pets to find the best upgrades and weakest sacrifice candidates.</p>
|
||||||
|
</div>
|
||||||
|
<section class="save-panel" aria-labelledby="save-heading">
|
||||||
|
<h2 id="save-heading">Save slots</h2>
|
||||||
|
<div class="save-grid">
|
||||||
|
<input id="slotName" type="text" placeholder="Slot name" />
|
||||||
|
<button id="saveSlot" type="button">Save</button>
|
||||||
|
<select id="slotSelect" aria-label="Saved slots"></select>
|
||||||
|
<button id="loadSlot" type="button">Load</button>
|
||||||
|
<button id="deleteSlot" type="button" class="danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
<p id="saveStatus" class="status" role="status"></p>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="controls card">
|
||||||
|
<label>
|
||||||
|
Strategy profile
|
||||||
|
<select id="profileSelect"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Recommendation count
|
||||||
|
<input id="recommendationCount" type="number" min="3" max="20" step="1" value="8" />
|
||||||
|
</label>
|
||||||
|
<button id="resetBuild" type="button" class="secondary">Reset current build</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="summary-grid" aria-label="Build summary">
|
||||||
|
<article class="metric"><span>Total damage base</span><strong id="totalDamage">0</strong></article>
|
||||||
|
<article class="metric"><span>Total health base</span><strong id="totalHealth">0</strong></article>
|
||||||
|
<article class="metric"><span>Offense score</span><strong id="offenseScore">0</strong></article>
|
||||||
|
<article class="metric"><span>Defense score</span><strong id="defenseScore">0</strong></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="results">
|
||||||
|
<article class="card">
|
||||||
|
<h2>Best upgrades</h2>
|
||||||
|
<ol id="upgradeList" class="recommendation-list"></ol>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h2>Best sacrifice candidates</h2>
|
||||||
|
<ol id="sacrificeList" class="recommendation-list"></ol>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Effect totals</h2>
|
||||||
|
<div id="effectTotals" class="effect-totals"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Equipment</h2>
|
||||||
|
<p>Each piece can have zero, one, or two effects. Empty effects are ignored.</p>
|
||||||
|
</div>
|
||||||
|
<div id="gearGrid" class="gear-grid"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<template id="gearTemplate">
|
||||||
|
<article class="gear-card card">
|
||||||
|
<div class="gear-title-row">
|
||||||
|
<h3></h3>
|
||||||
|
<span class="piece-score"></span>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input data-field="name" type="text" />
|
||||||
|
</label>
|
||||||
|
<div class="inline-fields">
|
||||||
|
<label>
|
||||||
|
Base stat
|
||||||
|
<select data-field="baseType">
|
||||||
|
<option value="damage">Damage</option>
|
||||||
|
<option value="health">Health</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Value
|
||||||
|
<input data-field="baseValue" type="number" min="0" step="1" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="effects"></div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+162
@@ -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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user