Download static/forge_stats.js from oettaM123/swordmasters-optimizer: direct link, hf CLI and curl.
- Browser
- Download file 54.7 kB
-
https://huggingface.co/spaces/oettaM123/swordmasters-optimizer/resolve/main/static/forge_stats.js
- Command line
-
hf download hf://spaces/oettaM123/swordmasters-optimizer/static/forge_stats.js
-
curl -L -o forge_stats.js https://huggingface.co/spaces/oettaM123/swordmasters-optimizer/resolve/main/static/forge_stats.js
54.7 kB
| /* forge_stats.js — Aktions-Statistiken (dwarf/elf/pet/smelt/enchant/arena/downgrade). | |
| * Player-Lookup-Unterkategorie (#ai-forge-panel) + Untertab im Leaderboards-Tab. | |
| * Balken-Laenge = VERSUCHE; Erfolg farbig, Fail rot. Nutzt base()/_fp3() aus index.html. */ | |
| (function () { | |
| "use strict"; | |
| const $ = (id) => document.getElementById(id); | |
| const ACTION_LABEL = { dwarf: "Dwarf Forge", elf: "Elf", pet_enhance: "Pet Enhance", | |
| smelt: "Smelt", enchant: "Enchant", arena: "Arena", nightmare: "Nightmare Arena", downgrade: "Downgrade" }; | |
| const LOOKUP_ORDER = ["dwarf", "elf", "pet_enhance", "smelt", "enchant", "arena", "nightmare", "downgrade"]; | |
| // Aktion -> breakdown-dim fuer die globalen Totals (deckt sich mit item_totals/forge_api). | |
| const TOTAL_DIM = { arena: "drop", nightmare: "drop", enchant: "enchant", smelt: "ore", | |
| elf: "input", pet_enhance: "pet", downgrade: "level" }; | |
| const RARITIES = ["Common", "Rare", "Epic", "Legendary"]; | |
| const ROMAN = ["", "I", "II", "III", "IV", "V"]; | |
| const ENCH_COLOR = { Flame: "#ff6a3d", Thorns: "#7ee081", Looting: "#ffd23d", | |
| Freeze: "#5fc8ff", Fortune: "#ffb020" }; | |
| const ENCH_LV = { 1: "#8fd0ff", 2: "#4ea3ff", 3: "#2f6fe0" }; // Enchant L1→L3 (Blau-Rampe) | |
| const ORE_COLOR = { Coal: "#8894a6", Iron: "#c9d1dc", Diamond: "#5fe6ff", Emerald: "#42e695", | |
| Ruby: "#ff5a7a", Gold: "#ffcc33", Sapphire: "#5a8cff" }; | |
| const GREEN = "var(--green,#00e676)", RED = "#a51d2a", BLUE = "#4ea3ff"; // Fail = dunkles Rot | |
| // Erfolgs-Verb je Aktion (Header + Balken-Tooltips) | |
| const SUCCESS_VERB = { dwarf: "forged", elf: "combined", pet_enhance: "enhanced", | |
| smelt: "smelted", enchant: "enchanted", arena: "found", nightmare: "found", downgrade: "downgraded" }; | |
| // Elf: Epic-Input -> Legendary-Output (aus Katalog-Themen + Logdaten bestaetigt). | |
| const ELF_MAP = { "dragon's poison": "Dragon's Devil", "solbrand": "Divinity Edge", | |
| "soulkeeper's blade": "Last Horizon", "dragon's anger": "Dragon's Soul", | |
| "sunward bulwark": "Asgardian Aegis", "sealguard": "Final Bastion" }; | |
| const rarityColor = (r) => ({ Common: "#9fb0c3", Rare: "#4ea3ff", Epic: "#b060ff", Legendary: "#ffb020" }[r] || "var(--accent,#00c8ff)"); | |
| const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, | |
| (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); | |
| const enc = (s) => encodeURIComponent(String(s == null ? "" : s)).replace(/'/g, "%27"); | |
| const fInt = (n) => (n == null ? "0" : Number(n).toLocaleString("en-US")); | |
| function fShort(n) { n = Number(n) || 0; const a = Math.abs(n); | |
| if (a >= 1e6) return (n / 1e6).toFixed(a >= 1e7 ? 1 : 2) + "M"; | |
| if (a >= 1e3) return (n / 1e3).toFixed(a >= 1e4 ? 0 : 1) + "k"; return String(n); } | |
| const fPct = (x) => (x == null ? "–" : (x * 100).toFixed(1) + "%"); | |
| const fPct0 = (x) => (x == null ? "–" : Math.round(x * 100) + "%"); | |
| const _fpow = (n) => (typeof _fp3 === "function" ? _fp3(n) : String(Math.round(n))); | |
| const fVal = (net) => (net >= 0 ? "+" : "") + _fpow(net); | |
| const luckClass = (p) => (p == null ? "" : p >= 0.6 ? "f-lucky" : p <= 0.4 ? "f-unlucky" : "f-neutral"); | |
| const rcls = (r) => "r-" + String(r || "").toLowerCase(); | |
| function ago(dateStr) { | |
| if (!dateStr) return ""; | |
| const d = Date.parse(dateStr + "T00:00:00Z"); if (isNaN(d)) return dateStr; | |
| const days = Math.floor((Date.now() - d) / 86400000); | |
| return days <= 0 ? "today" : days === 1 ? "1 day ago" : days + " days ago"; | |
| } | |
| async function api(path) { | |
| const r = await fetch(`${(typeof base === "function" ? base() : "")}/forge${path}`); | |
| if (!r.ok) { let e = {}; try { e = await r.json(); } catch (_) {} throw new Error((e && e.detail) || `HTTP ${r.status}`); } | |
| return r.json(); | |
| } | |
| // Split-Balken: Laenge ∝ Summe der Segmente; Segmente farbig; Zahl je Segment IM | |
| // Segment (wenn Platz), Gesamtsumme rechts. Kein Zahlenblock mehr vorne. | |
| function splitBar(labelHtml, segs, maxTotal, opts) { | |
| opts = opts || {}; | |
| segs = segs.filter((s) => s.v > 0); | |
| const total = segs.reduce((s, x) => s + x.v, 0); | |
| const fillPct = maxTotal > 0 ? Math.max(1.5, total / maxTotal * 100) : 0; | |
| // Zahl OPTIMISTISCH in JEDES Segment (auch bei nur einer Farbe). measureBar entscheidet | |
| // nach dem Layout: passt alles rein -> drin lassen; passt EINE nicht -> ALLE Zahlen (in | |
| // Reihenfolge, je Segmentfarbe) direkt HINTER das Balkenende. | |
| const inner = segs.map((s) => `<span class="sbar-seg" style="flex:0 0 ${total ? s.v / total * 100 : 0}%;background:${s.color}" title="${esc(s.label || "")}: ${fInt(s.v)}"><span class="sbar-num" data-c="${s.color}">${fInt(s.v)}</span></span>`).join(""); | |
| const arrow = opts.clickable ? '<span class="fbar-go">▸</span>' : ""; | |
| return `<div class="fbar${opts.clickable ? " fbar-click" : ""}"${opts.onclick ? ` onclick="${opts.onclick}"` : ""}> | |
| <div class="fbar-l">${labelHtml}</div> | |
| <div class="fbar-track"><div class="fbar-fill" style="flex:0 0 ${fillPct}%">${inner}</div></div>${arrow}</div>`; | |
| } | |
| // Nach dem Layout messen: passen ALLE Segment-Zahlen rein -> drin lassen. Passt EINE nicht | |
| // -> ALLE Zahlen (in Segment-Reihenfolge, je Segmentfarbe) direkt HINTER das Balkenende. | |
| // So bleibt die Reihenfolge klar und die Zahlen kleben am Balken (nicht am rechten Rand). | |
| function measureBar(bar) { | |
| if (bar.dataset.measured) return; | |
| const fill = bar.querySelector(".fbar-fill"); | |
| const track = bar.querySelector(".fbar-track"); | |
| if (!fill || !track || !track.clientWidth) return; // noch nicht sichtbar -> spaeter | |
| const segs = [...fill.querySelectorAll(".sbar-seg")]; | |
| if (!segs.length) { bar.dataset.measured = "1"; return; } | |
| let overflow = false; | |
| segs.forEach((seg) => { | |
| const n = seg.querySelector(".sbar-num"); | |
| if (n && n.offsetWidth > seg.clientWidth + 1) overflow = true; | |
| }); | |
| if (overflow) { | |
| const items = segs.map((seg) => { | |
| const n = seg.querySelector(".sbar-num"); | |
| return n ? `<b style="color:${n.dataset.c}">${n.textContent}</b>` : ""; | |
| }).filter(Boolean); | |
| segs.forEach((seg) => { const n = seg.querySelector(".sbar-num"); if (n) n.remove(); }); | |
| const after = document.createElement("span"); | |
| after.className = "fbar-after"; | |
| after.innerHTML = items.join("·"); | |
| track.appendChild(after); // direkt hinter dem Fill (Flex) -> klebt am Balken | |
| } | |
| bar.dataset.measured = "1"; | |
| } | |
| function measureIn(node) { | |
| if (node.nodeType !== 1) return; | |
| if (node.matches && node.matches(".fbar")) measureBar(node); | |
| if (node.querySelectorAll) node.querySelectorAll(".fbar").forEach(measureBar); | |
| } | |
| let _barObs = null; | |
| function initBarMeasure() { | |
| if (_barObs || typeof MutationObserver !== "function") return; | |
| _barObs = new MutationObserver((muts) => { | |
| muts.forEach((m) => m.addedNodes.forEach(measureIn)); | |
| }); | |
| ["ai-forge-panel", "tab-leaderboard"].forEach((id) => { | |
| const r = $(id); if (r) _barObs.observe(r, { childList: true, subtree: true }); | |
| }); | |
| } | |
| const NAME_NOTE = `<div class="forge-namenote">📌 Everything here is by <b>display name</b>, not account — a rename starts a fresh history.</div>`; | |
| const sw = (c) => `<span class="lg-sw" style="background:${c}"></span>`; | |
| const LEGEND_NOTE = `<div class="forge-legend">Each bar's length = <b>attempts</b>. Inside, a bar is split into its outcomes: | |
| ${sw("var(--green,#00e676)")}any <b>coloured</b> part = a success, ${sw(RED)}dark red = a fail. For enchants the coloured parts are the | |
| <b>enchant level reached</b> (${sw(ENCH_LV[1])}L1 ${sw(ENCH_LV[2])}L2 ${sw(ENCH_LV[3])}L3); for dwarf/elf they're coloured by item rarity.</div>`; | |
| async function ensureRates() {} // Attempts sind jetzt exakt (Shield-Burns rueckabgebildet) | |
| // ════════════════ PLAYER-LOOKUP ════════════════ | |
| async function loadForgeStats(name) { | |
| const panel = $("ai-forge-panel"); if (!panel) return; | |
| try { window.smTrack && smTrack("lookup:forge"); } catch (e) {} | |
| panel.style.display = ""; | |
| const body = $("ai-forge-body"), luck = $("ai-forge-luck"); | |
| body.innerHTML = '<div class="forge-empty">⟳ Loading…</div>'; luck.innerHTML = ""; | |
| try { | |
| await ensureRates(); | |
| const d = await api(`/player?name=${encodeURIComponent(name)}`); | |
| luck.innerHTML = luckBadgeHTML(d.overall); | |
| const ord = (x) => { const i = LOOKUP_ORDER.indexOf(x.action); return i < 0 ? 99 : i; }; | |
| const acts = (d.actions || []).slice().sort((a, b) => ord(a) - ord(b)); | |
| body.innerHTML = NAME_NOTE + LEGEND_NOTE + (acts.map((a) => actionCard(d.name, a)).join("") || '<div class="forge-empty">No forge actions logged.</div>'); | |
| } catch (e) { | |
| luck.innerHTML = ""; | |
| body.innerHTML = /No forge stats/i.test(e.message) | |
| ? '<div class="forge-empty">No forge activity logged for this name yet.</div>' | |
| : `<div class="forge-empty">✗ ${esc(e.message)}</div>`; | |
| } | |
| } | |
| function forgeHideStats() { const p = $("ai-forge-panel"); if (p) p.style.display = "none"; } | |
| // minimaler Header: nur Zahlen (keine Balken/Prozente) | |
| function actionCard(name, a) { | |
| let counts; | |
| const verb = SUCCESS_VERB[a.action] || "ok"; | |
| if (!a.has_rate) counts = `<b>${fInt(a.successes || a.attempts)}</b> ${verb}`; | |
| else counts = `<b class="f-lucky">${fInt(a.successes)}</b> ${verb} · <b class="f-unlucky">${fInt(a.fails)}</b> failed`; | |
| let extra = ""; | |
| // Luck pro Aktion = Prozent ueber/unter dem Erwarteten. NUR dwarf/elf/pet (dort sind die | |
| // Odds sauber; dwarf/elf inkl. Fortune). smelt/enchant/downgrade: kein Luck (Odds nicht | |
| // rekonstruierbar). arena: nur Legendary-Count. | |
| if (a.luck_pct != null) { | |
| const s = a.luck_pct >= 0 ? "+" : ""; | |
| extra += `<span class="forge-chip ${luckClass(a.luck_percentile)}" title="wie weit ueber/unter dem erwarteten Erfolg (echte Einzel-Wahrscheinlichkeiten)">luck ${s}${a.luck_pct.toFixed(1)}%</span>`; | |
| } | |
| // Value pro Aktion (dwarf/elf/pet): Coins ueber/unter dem erwarteten Wert (Input immer | |
| // verloren, Output nur bei Erfolg). fVal setzt das Vorzeichen selbst. | |
| if (a.value_luck_coins != null) { | |
| extra += `<span class="forge-chip ${luckClass(a.value_luck_percentile)}" title="Wert ueber/unter Erwartung — Input immer verloren, Output nur bei Erfolg (dwarf: Item L+1, elf: Legendary, pet: XPet)">value ${fVal(a.value_luck_coins)}</span>`; | |
| } | |
| if ((a.action === "arena" || a.action === "nightmare") && a.legendary != null) extra += `<span class="forge-chip f-legend">${fInt(a.legendary)} legendary</span>`; | |
| return `<div class="forge-action"> | |
| <div class="forge-action-head" onclick="forgeToggleDetail(this,'${enc(name)}','${a.action}')"> | |
| <span class="forge-action-name">${esc(ACTION_LABEL[a.action] || a.action)}</span> | |
| <span class="forge-action-counts">${counts}</span> | |
| <span class="forge-action-chips">${extra}</span><span class="forge-caret">▸</span></div> | |
| <div class="forge-action-detail" style="display:none"></div></div>`; | |
| } | |
| async function forgeToggleDetail(head, nameEnc, action) { | |
| const det = head.parentElement.querySelector(".forge-action-detail"); | |
| const caret = head.querySelector(".forge-caret"); | |
| if (det.style.display !== "none") { det.style.display = "none"; caret.textContent = "▸"; return; } | |
| try { window.smTrack && smTrack("stat:" + String(action).toLowerCase().replace(/[^a-z0-9_.\-]/g, "").slice(0, 39)); } catch (e) {} | |
| caret.textContent = "▾"; det.style.display = ""; det.innerHTML = '<div class="forge-empty">⟳…</div>'; | |
| try { const d = await api(`/player-detail?name=${nameEnc}&action=${action}&limit=1500`); | |
| det.innerHTML = renderDetail(action, d.dims, nameEnc) || '<div class="forge-empty">No breakdown logged.</div>'; | |
| } catch (e) { det.innerHTML = `<div class="forge-empty">✗ ${esc(e.message)}</div>`; } | |
| } | |
| function sec(title, html) { return `<div class="fdet-sec"><div class="fdet-title">${title}</div>${html}</div>`; } | |
| function renderDetail(action, dims, nameEnc) { | |
| const S = []; | |
| if (action === "dwarf" && dims.item) { | |
| // pro Item (egal Level): geschaetzte Versuche; Erfolg(rarity-Farbe)+Fail(rot) | |
| const byItem = {}; | |
| dims.item.forEach((r) => { const e = byItem[r.key] = byItem[r.key] || { su: 0, att: 0, rarity: r.rarity }; | |
| e.su += r.successes; e.att += r.attempts; }); | |
| const rows = Object.entries(byItem).map(([k, v]) => ({ key: k, ...v })).sort((a, b) => b.att - a.att); | |
| const max = Math.max(1, ...rows.map((r) => r.att)); | |
| S.push(sec("Items you tried to forge — click one for the level split", | |
| rows.map((r) => splitBar(`<span class="${rcls(r.rarity)}">${esc(r.key)}</span>`, | |
| [{ v: r.su, color: rarityColor(r.rarity), label: "forged" }, { v: Math.max(0, r.att - r.su), color: RED, label: "burned" }], | |
| max, { clickable: true, onclick: `forgeDwarfItem(this,'${nameEnc}','${enc(r.key)}')` })).join(""))); | |
| } else if (action === "enchant") { | |
| if (dims.enchant) { | |
| // je Typ ein Balken: L1 · L2 · L3 · Fail (Fail einheitlich hinten) | |
| const byType = {}; | |
| dims.enchant.forEach((r) => { const t = byType[r.key] = byType[r.key] || { fail: 0, lv: {} }; | |
| if (r.level === 0) t.fail += r.attempts; else t.lv[r.level] = (t.lv[r.level] || 0) + r.successes; }); | |
| const rows = Object.entries(byType).map(([t, v]) => ({ t, ...v, total: v.fail + Object.values(v.lv).reduce((a, b) => a + b, 0) })).sort((a, b) => b.total - a.total); | |
| const max = Math.max(1, ...rows.map((r) => r.total)); | |
| const encKey = `<div class="fdet-key">${sw(ENCH_LV[1])}L1 ${sw(ENCH_LV[2])}L2 ${sw(ENCH_LV[3])}L3 ${sw(RED)}fail</div>`; | |
| S.push(sec("Enchants — level reached vs fail (bar = attempts)", encKey + rows.map((r) => splitBar( | |
| `<span style="color:${ENCH_COLOR[r.t] || "#8fd0ff"}">${esc(r.t)}</span>`, | |
| [{ v: r.lv[1] || 0, color: ENCH_LV[1], label: "L1" }, | |
| { v: r.lv[2] || 0, color: ENCH_LV[2], label: "L2" }, | |
| { v: r.lv[3] || 0, color: ENCH_LV[3], label: "L3" }, | |
| { v: r.fail, color: RED, label: "fail" }], max)).join(""))); | |
| } | |
| if (dims.item) { const max = Math.max(1, ...dims.item.map((r) => r.successes)); | |
| S.push(sec("By item (successful enchants)", dims.item.slice(0, 25).map((r) => | |
| splitBar(esc(r.key), [{ v: r.successes, color: BLUE, label: "enchanted" }], max)).join(""))); } | |
| } else if (action === "smelt" && dims.ore) { | |
| const max = Math.max(1, ...dims.ore.map((r) => r.attempts)); | |
| S.push(sec("Smelting by ore (bar = attempts)", dims.ore.slice().sort((a, b) => b.attempts - a.attempts).map((r) => | |
| splitBar(`<span style="color:${ORE_COLOR[r.key] || "#ccc"}">${esc(r.key)}</span>`, | |
| [{ v: r.successes, color: ORE_COLOR[r.key] || GREEN, label: "smelted" }, { v: r.attempts - r.successes, color: RED, label: "fail" }], max)).join(""))); | |
| } else if (action === "downgrade" && dims.level) { | |
| const rows = dims.level.slice().sort((a, b) => a.level - b.level); | |
| const max = Math.max(1, ...rows.map((r) => r.attempts)); | |
| S.push(sec("Downgrades — pieces recovered vs burned (bar = attempts)", | |
| rows.map((r) => { | |
| const avg = r.attempts ? r.extra / r.attempts : 0; | |
| return splitBar(`L${r.level} <span class="fdet-lv">→ L${Math.max(1, r.level - 1)} · ⌀ ${avg.toFixed(1)} pcs</span>`, | |
| [{ v: r.successes, color: GREEN, label: "downgraded" }, { v: r.attempts - r.successes, color: RED, label: "burned" }], max); | |
| }).join("") | |
| + `<div class="forge-note">Downgrading one Level-N item returns up to N copies at Level N−1; a burn returns none. “⌀ pcs” = average pieces recovered per attempt.</div>`)); | |
| } else if ((action === "arena" || action === "nightmare") && dims.drop) { | |
| const rows = dims.drop.slice().sort((a, b) => b.successes - a.successes); | |
| const max = Math.max(1, ...rows.map((r) => r.successes)); | |
| S.push(sec((action === "nightmare" ? "Nightmare Arena loot" : "Arena loot") + " — items found", rows.map((r) => | |
| splitBar(`<span class="${rcls(r.rarity)}">${esc(r.key)}</span> <span class="fdet-lv">L${r.level}</span>`, | |
| [{ v: r.successes, color: rarityColor(r.rarity), label: "found" }], max, { breakdown: false })).join(""))); | |
| } else if (action === "elf" && dims.input) { | |
| // Nur EIN Set, benannt nach dem legendären Ergebnis (Input-Versuche = Legendary-Versuche). | |
| const rows = dims.input.slice() | |
| .map((r) => ({ out: ELF_MAP[String(r.key).toLowerCase()] || r.key, att: r.attempts, su: r.successes })) | |
| .sort((a, b) => b.att - a.att); | |
| const max = Math.max(1, ...rows.map((r) => r.att)); | |
| S.push(sec("Elf combines — legendary made vs burned (bar = attempts)", rows.map((r) => | |
| splitBar(`<span class="r-legendary">${esc(r.out)}</span>`, | |
| [{ v: r.su, color: rarityColor("Legendary"), label: "combined" }, | |
| { v: Math.max(0, r.att - r.su), color: RED, label: "burned" }], max)).join(""))); | |
| } else if (action === "pet_enhance" && dims.pet) { | |
| // nach Versuchsanzahl sortiert (meiste Versuche oben) | |
| const rows = dims.pet.slice().sort((a, b) => b.attempts - a.attempts); | |
| const max = Math.max(1, ...rows.map((r) => r.attempts)); | |
| S.push(sec("Pet enhancing — by attempts (bar = attempts)", rows.map((r) => | |
| splitBar(`${esc(r.key)} <span class="fdet-lv">${fPct0(r.successes / r.attempts)}</span>`, | |
| [{ v: r.successes, color: BLUE, label: "enhanced" }, { v: r.attempts - r.successes, color: RED, label: "fail" }], max)).join(""))); | |
| } | |
| return S.join(""); | |
| } | |
| // Dwarf-Item -> Level-Aufteilung (nur versuchte Level, 1 oben) | |
| async function forgeDwarfItem(barEl, nameEnc, itemEnc) { | |
| let host = barEl.nextElementSibling; | |
| if (host && host.classList.contains("fdwarf-levels")) { host.remove(); return; } | |
| host = document.createElement("div"); host.className = "fdwarf-levels"; | |
| host.innerHTML = '<div class="forge-empty">⟳…</div>'; barEl.after(host); | |
| try { | |
| // nameEnc === "@@combine" -> kombinierte Mehr-Account-Ansicht (names=), sonst ein Account. | |
| const q = nameEnc === "@@combine" ? `names=${encodeURIComponent(combineNames)}` : `name=${nameEnc}`; | |
| const d = await api(`/player-detail?${q}&action=dwarf&limit=1500`); | |
| const item = decodeURIComponent(itemEnc); | |
| const rows = (d.dims.item || []).filter((r) => r.key === item) | |
| .map((r) => ({ level: r.level, rarity: r.rarity, su: r.successes, att: r.attempts })) | |
| .sort((a, b) => a.level - b.level); | |
| const max = Math.max(1, ...rows.map((r) => r.att)); | |
| host.innerHTML = rows.map((r) => splitBar(`L${r.level}`, | |
| [{ v: r.su, color: rarityColor(r.rarity), label: "forged" }, { v: Math.max(0, r.att - r.su), color: RED, label: "burned" }], max)).join(""); | |
| } catch (e) { host.innerHTML = `<div class="forge-empty">✗ ${esc(e.message)}</div>`; } | |
| } | |
| // ════════════════ LEADERBOARDS-TAB ════════════════ | |
| let injected = false, tabReady = false, meta = null, itemsLoaded = false, boardLoaded = false; | |
| const lb = { action: "dwarf", sort: "attempts", q: "", offset: 0, limit: 25 }; | |
| let lbSeq = 0, lbTimer = null; | |
| function injectIntoLeaderboardTab() { | |
| if (injected) return; injected = true; | |
| const lt = $("tab-leaderboard"); if (!lt) return; | |
| const live = document.createElement("div"); live.id = "lb-live-wrap"; | |
| while (lt.firstChild) live.appendChild(lt.firstChild); | |
| const bar2 = document.createElement("div"); bar2.className = "forge-subtabs lb-topsubs"; | |
| bar2.innerHTML = `<button class="forge-subtab active" data-lbsub="live" onclick="lbSubtab('live')">Player Leaderboard</button> | |
| <button class="forge-subtab" data-lbsub="forge" onclick="lbSubtab('forge')">Forge & Actions</button> | |
| <button class="forge-subtab" data-lbsub="mob" onclick="lbSubtab('mob')">Mob Kills</button>`; | |
| const forge = document.createElement("div"); forge.id = "lb-forge-wrap"; forge.style.display = "none"; | |
| forge.innerHTML = forgeTabHTML(); | |
| const mob = document.createElement("div"); mob.id = "lb-mob-wrap"; mob.style.display = "none"; | |
| mob.innerHTML = (typeof window.mobTabHTML === "function") ? window.mobTabHTML() : ""; | |
| lt.appendChild(bar2); lt.appendChild(live); lt.appendChild(forge); lt.appendChild(mob); | |
| } | |
| function forgeTabHTML() { | |
| return `<div class="panel"><div class="ph"><h2>Forge & Action Stats</h2> | |
| <div id="forge-meta-summary" class="forge-meta-summary"></div></div> | |
| <div class="pb">${NAME_NOTE}${LEGEND_NOTE} | |
| <div class="forge-subtabs"> | |
| <button class="forge-subtab active" data-fsub="board" onclick="forgeSubtab('board')">Leaderboard</button> | |
| <button class="forge-subtab" data-fsub="totals" onclick="forgeSubtab('totals')">Totals</button></div> | |
| <div id="forge-sub-totals" style="display:none"> | |
| <div class="forge-controls"> | |
| <label>Show<select id="forge-totals-action" onchange="loadForgeTotals()"></select></label></div> | |
| <div id="forge-totals-head" class="fdh-sub"></div> | |
| <div id="forge-totals-dwarf" style="display:none"><div id="forge-items-grid"></div><div id="forge-items-drill"></div></div> | |
| <div id="forge-totals-body"></div></div> | |
| <div id="forge-sub-board"> | |
| <div class="forge-controls"> | |
| <label>Action<select id="forge-lb-action" onchange="loadForgeLeaderboard(true)"></select></label> | |
| <label>Sort by<select id="forge-lb-sort" onchange="loadForgeLeaderboard(true)"></select></label> | |
| <input type="text" id="forge-lb-q" placeholder="Search name…" oninput="forgeLbSearch()"></div> | |
| <div id="forge-lb-body"></div> | |
| <div class="forge-pager"><button id="forge-lb-prev" onclick="forgeLbPage(-1)">‹ Prev</button> | |
| <span id="forge-lb-pageinfo"></span><button id="forge-lb-next" onclick="forgeLbPage(1)">Next ›</button></div></div> | |
| </div></div>`; | |
| } | |
| function forgeOnLeaderboardOpen() { injectIntoLeaderboardTab(); } | |
| function lbSubtab(which) { | |
| document.querySelectorAll(".lb-topsubs .forge-subtab").forEach((b) => b.classList.toggle("active", b.dataset.lbsub === which)); | |
| $("lb-live-wrap").style.display = which === "live" ? "" : "none"; | |
| $("lb-forge-wrap").style.display = which === "forge" ? "" : "none"; | |
| const mw = $("lb-mob-wrap"); if (mw) mw.style.display = which === "mob" ? "" : "none"; | |
| if (which === "forge") loadForgeTab(); | |
| if (which === "mob" && typeof window.mobOnShow === "function") window.mobOnShow(); | |
| } | |
| async function loadForgeTab() { | |
| if (tabReady) return; tabReady = true; | |
| try { | |
| meta = await api("/meta"); | |
| const upd = meta.data_through ? `data through ${meta.data_through} (${ago(meta.data_through)})` : ""; | |
| $("forge-meta-summary").innerHTML = `${fInt(meta.player_count)} names · ${fInt(+meta.meta.total_events)} events${upd ? " · " + upd : ""}`; | |
| const actsOrdered = (meta.actions || []).slice().sort((a, b) => { | |
| const ia = LOOKUP_ORDER.indexOf(a), ib = LOOKUP_ORDER.indexOf(b); | |
| return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib); | |
| }); | |
| $("forge-lb-action").innerHTML = actsOrdered.map((a) => `<option value="${a}">${esc(ACTION_LABEL[a] || a)}</option>`).join(""); | |
| syncSortOptions(); | |
| } catch (e) { $("forge-meta-summary").textContent = "✗ " + e.message; } | |
| forgeSubtab("board"); // Leaderboard ist die Standardansicht | |
| } | |
| function syncSortOptions() { | |
| const action = $("forge-lb-action").value, dwarf = action === "dwarf"; | |
| const drop = action === "arena" || action === "nightmare"; | |
| const opts = []; | |
| if (drop) opts.push(["legendary", "Most legendarys"]); // Arena/Nightmare: nach Legendary-Drops | |
| opts.push(["attempts", "Most attempts"]); | |
| if (dwarf) opts.push(["gain", "Highest value gain"], ["loss", "Biggest value loss"]); | |
| opts.push(["luckiest", "Luckiest"], ["unluckiest", "Unluckiest"]); // fuer alle | |
| const sel = $("forge-lb-sort"), cur = sel.value; | |
| sel.innerHTML = opts.map(([v, l]) => `<option value="${v}">${l}</option>`).join(""); | |
| sel.value = opts.some((o) => o[0] === cur) ? cur : opts[0][0]; | |
| } | |
| async function loadForgeLeaderboard(reset) { | |
| const action = $("forge-lb-action").value; syncSortOptions(); | |
| const sort = $("forge-lb-sort").value, q = $("forge-lb-q").value.trim(); | |
| if (reset) lb.offset = 0; | |
| Object.assign(lb, { action, sort, q }); | |
| const seq = ++lbSeq, body = $("forge-lb-body"); | |
| body.innerHTML = '<div class="forge-empty">⟳ Loading…</div>'; | |
| try { | |
| const d = await api(`/leaderboard?action=${action}&sort=${sort}&q=${encodeURIComponent(q)}&offset=${lb.offset}&limit=${lb.limit}`); | |
| if (seq !== lbSeq) return; | |
| if (!d.rows.length) { body.innerHTML = '<div class="forge-empty">No names found.</div>'; } | |
| else body.innerHTML = `<table class="forge-tbl forge-lb"><thead><tr><th>#</th><th>Name</th> | |
| <th>${d.has_rate ? "Success / Attempts" : "Runs"}</th>${d.is_arena ? "<th>Legendary</th>" : "<th>Luck</th>"}${d.has_value ? "<th>Value</th>" : ""}</tr></thead><tbody>${ | |
| d.rows.map((r, i) => { | |
| const rank = r.rank != null ? r.rank : (lb.offset + i + 1); | |
| const cnt = d.has_rate ? `<div class="lb-count"><span>${fInt(r.successes)} / ${fInt(r.attempts)}</span><span class="lb-mini">${fPct0(r.rate)}</span></div>` : fInt(r.attempts); | |
| let c3; | |
| if (d.is_arena) c3 = `<td class="f-legend">${fInt(r.legendary)} <span class="lb-mini">${fPct(r.legendary_ratio)}</span></td>`; | |
| else c3 = r.luck_z != null ? `<td class="${luckClass(r.luck_percentile)}">${r.luck_z >= 0 ? "+" : ""}${r.luck_z.toFixed(2)}</td>` : "<td>–</td>"; | |
| const val = d.has_value ? `<td class="${(r.value_net || 0) >= 0 ? "f-lucky" : "f-unlucky"}">${r.value_known ? fVal(r.value_net || 0) : "–"}</td>` : ""; | |
| return `<tr data-name="${esc(r.name)}"><td class="forge-rank">${fInt(rank)}</td> | |
| <td class="forge-lb-name" onclick="forgeLbClickName(this,'${enc(r.name)}','${action}')">${esc(r.name)}</td><td>${cnt}</td>${c3}${val}</tr>`; | |
| }).join("")}</tbody></table>`; | |
| $("forge-lb-pageinfo").textContent = d.rows.length ? `${lb.offset + 1}–${lb.offset + d.rows.length}` : "0"; | |
| $("forge-lb-prev").disabled = lb.offset <= 0; $("forge-lb-next").disabled = !d.has_more; | |
| } catch (e) { body.innerHTML = `<div class="forge-empty">✗ ${esc(e.message)}</div>`; } | |
| } | |
| function forgeLbPage(dir) { lb.offset = Math.max(0, lb.offset + dir * lb.limit); loadForgeLeaderboard(false); } | |
| function forgeLbSearch() { clearTimeout(lbTimer); lbTimer = setTimeout(() => loadForgeLeaderboard(true), 350); } | |
| // 1. Klick: Aktion-Stats inline; 2. Klick: wieder zu. Voller Lookup via kleinem Button. | |
| async function forgeLbClickName(td, nameEnc, action) { | |
| const tr = td.parentElement; | |
| const nxt = tr.nextElementSibling; | |
| if (nxt && nxt.classList.contains("lb-inline")) { nxt.remove(); return; } // 2. Klick: schliessen | |
| document.querySelectorAll("#forge-lb-body .lb-inline").forEach((x) => x.remove()); | |
| const cols = tr.children.length; | |
| const row = document.createElement("tr"); row.className = "lb-inline"; | |
| row.innerHTML = `<td colspan="${cols}"><div class="lb-inline-box"><div class="forge-empty">⟳…</div></div></td>`; | |
| tr.after(row); | |
| const box = row.querySelector(".lb-inline-box"); | |
| try { | |
| const d = await api(`/player-detail?name=${nameEnc}&action=${action}`); | |
| box.innerHTML = `<div class="lb-inline-head"><button class="flookup" onclick="event.stopPropagation();forgeOpenPlayer('${nameEnc}')" title="Open the full player lookup">🔍 lookup</button><span>${esc(ACTION_LABEL[action] || action)} — ${esc(decodeURIComponent(nameEnc))}</span></div>` | |
| + (renderDetail(action, d.dims, nameEnc) || '<div class="forge-empty">No breakdown.</div>'); | |
| } catch (e) { box.innerHTML = `<div class="forge-empty">✗ ${esc(e.message)}</div>`; } | |
| } | |
| function forgeOpenPlayer(nameEnc) { | |
| const name = decodeURIComponent(nameEnc), inp = $("ai-name"); | |
| if (inp) inp.value = name; | |
| if (typeof switchTab === "function") switchTab("info"); | |
| if (typeof aiLive === "function") aiLive(); | |
| } | |
| function forgeSubtab(which) { | |
| document.querySelectorAll("#lb-forge-wrap .pb > .forge-subtabs .forge-subtab").forEach((b) => b.classList.toggle("active", b.dataset.fsub === which)); | |
| ["totals", "board"].forEach((s) => { | |
| const el = $("forge-sub-" + s); if (el) el.style.display = which === s ? "" : "none"; | |
| }); | |
| if (which === "totals") forgeTotalsInit(); | |
| if (which === "board" && !boardLoaded) { boardLoaded = true; loadForgeLeaderboard(true); } | |
| } | |
| // ── Luck-Badge (geteilt: Spieler-Lookup + Combine-Ansicht) ── | |
| function luckBadgeHTML(o) { | |
| if (!o) return ""; | |
| const p = o.luck_percentile, z = o.luck_z; | |
| const pv = Math.round((p >= 0.5 ? p : 1 - p) * 100); | |
| const PLABEL = { dwarf: "forge", elf: "elf", pet_enhance: "pet", arena: "arena", nightmare: "nightmare" }; | |
| const across = (o.parts || []).map((x) => PLABEL[x] || x).join(" · "); | |
| const vc = o.value_luck_coins; | |
| const coins = (vc != null) ? ` · ${fVal(vc)} vs expected` : ""; | |
| return `<div class="forge-luck-badge ${luckClass(p)}" title="Wert-gewichtet: jedes Event zaehlt nach Coins-at-Risk"><span class="flb-big">${p >= 0.5 ? "Lucky" : "Unlucky"}</span> | |
| <span class="flb-sub">${p >= 0.5 ? "luckier" : "unluckier"} than ${pv}% · z=${z >= 0 ? "+" : ""}${z.toFixed(2)}${coins}${across ? " · across " + across : ""}</span></div>`; | |
| } | |
| // ════════════════ TOTALS (globale Stats je Aktion, wie die Item-Tabelle) ════════════════ | |
| let totalsInit = false; | |
| function forgeTotalsInit() { | |
| if (totalsInit) return; totalsInit = true; | |
| const sel = $("forge-totals-action"); if (!sel) return; | |
| // Dwarf (Item-Tabellen) + alle Breakdown-Aktionen; Dwarf steht per LOOKUP_ORDER vorn. | |
| const have = (meta && meta.actions) ? meta.actions : LOOKUP_ORDER; | |
| const acts = have.filter((a) => a === "dwarf" || TOTAL_DIM[a]).slice() | |
| .sort((a, b) => LOOKUP_ORDER.indexOf(a) - LOOKUP_ORDER.indexOf(b)); | |
| if (!acts.includes("dwarf")) acts.unshift("dwarf"); | |
| sel.innerHTML = acts.map((a) => `<option value="${a}">${a === "dwarf" ? "Dwarf" : esc(ACTION_LABEL[a] || a)}</option>`).join(""); | |
| sel.value = "dwarf"; | |
| loadForgeTotals(); | |
| } | |
| function totalsHead(action) { | |
| const t = (meta && meta.totals || []).find((x) => x.action === action); | |
| if (!t) return ""; | |
| const rate = (t.has_rate && t.attempts) ? ` · ${fPct0(t.successes / t.attempts)} success` : ""; | |
| return t.has_rate | |
| ? `${fInt(t.attempts)} attempts · ${fInt(t.successes)} ${SUCCESS_VERB[action] || "ok"}${rate} — everyone combined` | |
| : `${fInt(t.attempts)} ${SUCCESS_VERB[action] || "events"} — everyone combined`; | |
| } | |
| async function loadForgeTotals() { | |
| const sel = $("forge-totals-action"); if (!sel || !sel.value) return; | |
| const action = sel.value; | |
| const head = $("forge-totals-head"), body = $("forge-totals-body"), dwarfBox = $("forge-totals-dwarf"); | |
| try { window.smTrack && smTrack("stat:totals_" + action); } catch (e) {} | |
| // Dwarf = die Item-Tabellen (Raster + Drill), nicht ein flacher Breakdown. | |
| if (action === "dwarf") { | |
| body.style.display = "none"; body.innerHTML = ""; | |
| dwarfBox.style.display = ""; | |
| head.innerHTML = totalsHead("dwarf"); | |
| if (!itemsLoaded) loadForgeItems(); | |
| return; | |
| } | |
| dwarfBox.style.display = "none"; body.style.display = ""; | |
| const dim = TOTAL_DIM[action] || "drop"; | |
| body.innerHTML = '<div class="forge-empty">⟳ Loading…</div>'; head.innerHTML = ""; | |
| try { | |
| const d = await api(`/breakdown?action=${action}&dim=${dim}&limit=500`); | |
| head.innerHTML = totalsHead(action); | |
| const dims = {}; | |
| dims[dim] = (d.rows || []).map((r) => ({ key: r.key, level: r.level, rarity: r.rarity, | |
| attempts: r.attempts, successes: r.successes, extra: 0 })); | |
| body.innerHTML = renderDetail(action, dims, "") || '<div class="forge-empty">No data logged yet.</div>'; | |
| } catch (e) { body.innerHTML = `<div class="forge-empty">✗ ${esc(e.message)}</div>`; } | |
| } | |
| // ════════════════ COMBINE ACCOUNTS (Dwarf-Multi: mehrere Accounts zusammen) ════════════════ | |
| let combineNames = ""; | |
| async function loadForgeCombined() { | |
| const inp = $("forge-combine-input"); if (!inp) return; | |
| const raw = (inp.value || "").trim(); combineNames = raw; | |
| const luck = $("forge-combine-luck"), body = $("forge-combine-body"); | |
| if (!raw) { body.innerHTML = '<div class="forge-empty">Enter at least one name.</div>'; luck.innerHTML = ""; return; } | |
| body.innerHTML = '<div class="forge-empty">⟳ Loading…</div>'; luck.innerHTML = ""; | |
| try { window.smTrack && smTrack("lookup:combine"); } catch (e) {} | |
| try { | |
| const d = await api(`/player?names=${encodeURIComponent(raw)}`); | |
| luck.innerHTML = luckBadgeHTML(d.overall); | |
| const ord = (x) => { const i = LOOKUP_ORDER.indexOf(x.action); return i < 0 ? 99 : i; }; | |
| const acts = (d.actions || []).slice().sort((a, b) => ord(a) - ord(b)); | |
| body.innerHTML = `<div class="forge-combine-names">Combined: <b>${esc(d.name)}</b></div>` | |
| + (acts.map((a) => combinedActionCard(a)).join("") || '<div class="forge-empty">No forge actions logged.</div>'); | |
| } catch (e) { | |
| body.innerHTML = /No forge stats/i.test(e.message) | |
| ? '<div class="forge-empty">No forge activity logged for these accounts.</div>' | |
| : `<div class="forge-empty">✗ ${esc(e.message)}</div>`; | |
| } | |
| } | |
| function combinedActionCard(a) { | |
| let counts; const verb = SUCCESS_VERB[a.action] || "ok"; | |
| if (!a.has_rate) counts = `<b>${fInt(a.successes || a.attempts)}</b> ${verb}`; | |
| else counts = `<b class="f-lucky">${fInt(a.successes)}</b> ${verb} · <b class="f-unlucky">${fInt(a.fails)}</b> failed`; | |
| let extra = ""; | |
| if (a.luck_pct != null) { const s = a.luck_pct >= 0 ? "+" : ""; extra += `<span class="forge-chip ${luckClass(a.luck_percentile)}">luck ${s}${a.luck_pct.toFixed(1)}%</span>`; } | |
| if (a.value_luck_coins != null) extra += `<span class="forge-chip ${luckClass(a.value_luck_percentile)}">value ${fVal(a.value_luck_coins)}</span>`; | |
| if ((a.action === "arena" || a.action === "nightmare") && a.legendary != null) extra += `<span class="forge-chip f-legend">${fInt(a.legendary)} legendary</span>`; | |
| return `<div class="forge-action"> | |
| <div class="forge-action-head" onclick="forgeCombinedToggle(this,'${a.action}')"> | |
| <span class="forge-action-name">${esc(ACTION_LABEL[a.action] || a.action)}</span> | |
| <span class="forge-action-counts">${counts}</span> | |
| <span class="forge-action-chips">${extra}</span><span class="forge-caret">▸</span></div> | |
| <div class="forge-action-detail" style="display:none"></div></div>`; | |
| } | |
| async function forgeCombinedToggle(head, action) { | |
| const det = head.parentElement.querySelector(".forge-action-detail"); | |
| const caret = head.querySelector(".forge-caret"); | |
| if (det.style.display !== "none") { det.style.display = "none"; caret.textContent = "▸"; return; } | |
| caret.textContent = "▾"; det.style.display = ""; det.innerHTML = '<div class="forge-empty">⟳…</div>'; | |
| try { | |
| const d = await api(`/player-detail?names=${encodeURIComponent(combineNames)}&action=${action}&limit=1500`); | |
| // "@@combine" als Quelle -> Dwarf-Items bleiben klickbar (Level-Verteilung wie im Einzel-Lookup). | |
| det.innerHTML = renderDetail(action, d.dims, "@@combine") | |
| || '<div class="forge-empty">No breakdown logged.</div>'; | |
| } catch (e) { det.innerHTML = `<div class="forge-empty">✗ ${esc(e.message)}</div>`; } | |
| } | |
| // ── Item Tables: Grid + Drill (overview/rarity/level/cell -> item -> players) ── | |
| let drillStack = []; | |
| async function loadForgeItems() { | |
| itemsLoaded = true; await ensureRates(); | |
| const grid = $("forge-items-grid"); | |
| grid.innerHTML = '<div class="forge-empty">⟳ Loading…</div>'; | |
| try { | |
| const d = await api(`/items?action=dwarf`); | |
| const cells = {}, lvTot = {}; let maxL = 1; | |
| d.grid.forEach((g) => { if (!g.rarity || g.level < 1) return; | |
| const k = g.rarity + "|" + g.level; (cells[k] = cells[k] || { att: 0, su: 0 }); | |
| cells[k].att += g.attempts; cells[k].su += g.successes; lvTot[g.level] = (lvTot[g.level] || 0) + g.attempts; }); | |
| for (let l = 1; l <= 40; l++) if ((lvTot[l] || 0) >= 100) maxL = l; | |
| let h = '<div class="forge-scroll"><table class="forge-tbl forge-items"><thead><tr><th>Rarity ╲ Lv</th>'; | |
| for (let l = 1; l <= maxL; l++) h += `<th class="fcol" onclick="forgeDrillReset('level',null,${l})">${l}</th>`; | |
| h += "</tr></thead><tbody>"; | |
| RARITIES.forEach((rr) => { h += `<tr><td class="${rcls(rr)} forge-rlabel frow" onclick="forgeDrillReset('rarity','${rr}',null)">${rr}</td>`; | |
| for (let l = 1; l <= maxL; l++) { const c = cells[rr + "|" + l]; | |
| if (c && c.att) h += `<td class="forge-cell" onclick="forgeDrillReset('cell','${rr}',${l})"><span class="forge-cell-n">${fShort(c.att)}</span><span class="forge-cell-rate">${fPct0(c.su / c.att)}</span></td>`; | |
| else h += '<td class="forge-cell-empty">·</td>'; } h += "</tr>"; }); | |
| h += "</tbody></table></div><div class=\"forge-note\">Big number = forge ATTEMPTS (exact); below = success rate. Click a cell, a rarity (row) or a level (column) — or an item — to drill in.</div>"; | |
| grid.innerHTML = h; | |
| forgeDrillReset("all", null, null); // Overview | |
| } catch (e) { grid.innerHTML = `<div class="forge-empty">✗ ${esc(e.message)}</div>`; } | |
| } | |
| // Overview ist immer die Basis; Zellen/Zeilen/Spalten setzen darauf auf -> "back" fuehrt | |
| // zurueck bis zur Ansicht mit ALLEN Items. | |
| function forgeDrillReset(scope, rarity, level) { | |
| drillStack = scope === "all" ? [] : [() => drillDist("all", null, null)]; | |
| drillPush(() => drillDist(scope, rarity, level)); | |
| } | |
| function drillPush(fn) { drillStack.push(fn); fn(); } | |
| function drillBack() { drillStack.pop(); const f = drillStack[drillStack.length - 1]; if (f) f(); } | |
| function drillHead(title) { | |
| const back = drillStack.length > 1 ? `<button class="fback" onclick="forgeDrillBack()">‹ back</button>` : ""; | |
| return `<div class="forge-drill-head">${back}${title}</div>`; | |
| } | |
| async function drillDist(scope, rarity, level) { | |
| const drill = $("forge-items-drill"); drill.innerHTML = '<div class="forge-empty">⟳…</div>'; | |
| const qp = []; if (rarity) qp.push("rarity=" + rarity); if (level != null) qp.push("level=" + level); | |
| try { | |
| const d = await api(`/item-dist?${qp.join("&")}&limit=400`); | |
| const title = scope === "all" ? "All items by forge attempts (any level)" | |
| : scope === "rarity" ? `<span class="${rcls(rarity)}">${rarity}</span> — items by attempts (all levels)` | |
| : scope === "level" ? `Level ${level} — items by attempts (all rarities)` | |
| : `<span class="${rcls(rarity)}">${rarity}</span> · Level ${level}`; | |
| const sub = `<div class="fdh-sub">${fInt(d.tot_attempts)} attempts · ${fPct0(d.tot_successes / d.tot_attempts)} success</div>`; | |
| const max = Math.max(1, ...d.items.map((r) => r.attempts)); | |
| // In einer ZELLE (Rarität+Level fix) direkt zur Spielerverteilung; sonst erst Level-Split. | |
| const cell = scope === "cell"; | |
| drill.innerHTML = drillHead(title + sub) + (d.items.map((r) => splitBar( | |
| `<span class="${rcls(r.rarity)}">${esc(r.item)}</span>`, | |
| [{ v: r.successes, color: rarityColor(r.rarity), label: "forged" }, { v: Math.max(0, r.attempts - r.successes), color: RED, label: "burned" }], | |
| max, { clickable: true, onclick: cell | |
| ? `forgeDrillPlayers('${enc(r.item)}',${level},'${r.rarity}')` | |
| : `forgeDrillItem('${enc(r.item)}','${r.rarity}')` })).join("") | |
| || '<div class="forge-empty">No items.</div>'); | |
| } catch (e) { drill.innerHTML = `<div class="forge-empty">✗ ${esc(e.message)}</div>`; } | |
| } | |
| function forgeDrillItem(itemEnc, rarity) { drillPush(() => drillItemLevels(itemEnc, rarity)); } | |
| async function drillItemLevels(itemEnc, rarity) { | |
| const drill = $("forge-items-drill"); drill.innerHTML = '<div class="forge-empty">⟳…</div>'; | |
| const item = decodeURIComponent(itemEnc); | |
| try { | |
| const d = await api(`/item-levels?item=${encodeURIComponent(item)}&rarity=${rarity}`); | |
| const max = Math.max(1, ...d.levels.map((r) => r.attempts)); | |
| drill.innerHTML = drillHead(`<span class="${rcls(rarity)}">${esc(item)}</span> — attempts by level (click a level for players)`) | |
| + (d.levels.map((r) => splitBar(`L${r.level}`, | |
| [{ v: r.successes, color: rarityColor(rarity), label: "forged" }, { v: r.fails, color: RED, label: "burned" }], | |
| max, { clickable: true, onclick: `forgeDrillPlayers('${enc(item)}',${r.level},'${rarity}')` })).join("") | |
| || '<div class="forge-empty">No levels.</div>'); | |
| } catch (e) { drill.innerHTML = `<div class="forge-empty">✗ ${esc(e.message)}</div>`; } | |
| } | |
| function forgeDrillPlayers(itemEnc, level, rarity) { drillPush(() => drillPlayers(itemEnc, level, rarity)); } | |
| async function drillPlayers(itemEnc, level, rarity) { | |
| const drill = $("forge-items-drill"); drill.innerHTML = '<div class="forge-empty">⟳…</div>'; | |
| const item = decodeURIComponent(itemEnc); | |
| try { | |
| const d = await api(`/item-leaderboard?item=${encodeURIComponent(item)}&level=${level}&rarity=${rarity}&limit=100`); | |
| const t = d.tot || { attempts: 0, successes: 0 }; | |
| const head = drillHead(`Top forgers of <span class="${rcls(rarity)}">${esc(item)}</span> · L${level} <span class="fdh-sub">${fInt(t.attempts)} attempts · ${fPct0(t.successes / (t.attempts || 1))} success</span>`); | |
| if (!d.rows.length) { | |
| const msg = t.attempts | |
| ? `Forged ${fInt(t.attempts)}× (${fInt(t.successes)} success, ${fInt(t.attempts - t.successes)} burned). The forger(s) have almost no other logged activity, so they aren’t listed individually.` | |
| : "No forge attempts recorded for this combination."; | |
| drill.innerHTML = head + `<div class="forge-empty">${msg}</div>`; return; | |
| } | |
| const max = Math.max(1, ...d.rows.map((r) => r.attempts || r.successes)); | |
| drill.innerHTML = head + d.rows.map((r, i) => splitBar( | |
| `<span class="forge-lb-name" onclick="event.stopPropagation();forgeOpenPlayer('${enc(r.name)}')">${i + 1}. ${esc(r.name)}</span>`, | |
| [{ v: r.successes, color: rarityColor(rarity), label: "forged" }, { v: Math.max(0, (r.attempts || r.successes) - r.successes), color: RED, label: "burned" }], | |
| max)).join("") | |
| + `<div class="forge-note">Top ${d.rows.length}${d.has_more ? "+" : ""} forgers by attempts, out of ${fInt(t.attempts)} total attempts. Coloured = forged, red = burned.</div>`; | |
| } catch (e) { drill.innerHTML = `<div class="forge-empty">✗ ${esc(e.message)}</div>`; } | |
| } | |
| function injectCSS() { | |
| if ($("forge-stats-css")) return; | |
| const s = document.createElement("style"); s.id = "forge-stats-css"; | |
| s.textContent = ` | |
| #ai-forge-panel .ph{display:flex;align-items:center;gap:8px} | |
| .ai-forge-luck,.forge-meta-summary{margin-left:auto;font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3,#8ea);text-align:right} | |
| .forge-luck-badge{padding:5px 12px;border-radius:9px;border:1px solid rgba(255,255,255,.14);line-height:1.2} | |
| .forge-luck-badge .flb-big{font-size:14px;font-weight:800;display:block}.forge-luck-badge .flb-sub{font-size:9px;opacity:.8} | |
| .forge-luck-badge.f-lucky{background:rgba(0,230,118,.13);border-color:rgba(0,230,118,.4);color:var(--green,#00e676)} | |
| .forge-luck-badge.f-unlucky{background:rgba(255,64,80,.13);border-color:rgba(255,64,80,.4);color:var(--red,#ff5064)} | |
| .forge-luck-badge.f-neutral{background:rgba(255,255,255,.06);color:var(--text2,#cde)} | |
| .forge-namenote{font-size:10px;color:var(--text3,#8ea);background:rgba(255,255,255,.04);border-left:2px solid var(--accent,#00c8ff);padding:5px 9px;border-radius:0 6px 6px 0;margin-bottom:10px} | |
| .forge-legend{font-size:10px;color:var(--text2,#cde);background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:6px;padding:6px 10px;margin-bottom:11px;line-height:1.75} | |
| .lg-sw{display:inline-block;width:9px;height:9px;border-radius:2px;vertical-align:middle;margin:0 3px 2px 5px} | |
| .fdet-key{font-size:9px;color:var(--text3,#8ea);margin:-3px 0 5px} | |
| .forge-action{border:1px solid rgba(255,255,255,.08);border-radius:9px;margin-bottom:6px;background:rgba(255,255,255,.02)} | |
| .forge-action-head{display:flex;align-items:center;gap:10px;padding:9px 11px;cursor:pointer;flex-wrap:wrap} | |
| .forge-action-head:hover{background:rgba(255,255,255,.04)} | |
| .forge-action-name{font-weight:700;min-width:105px} | |
| .forge-action-counts{font-family:'Share Tech Mono',monospace;font-size:12px} | |
| .forge-action-chips{display:flex;gap:5px;flex-wrap:wrap;margin-left:auto} | |
| .forge-caret{color:var(--text3,#8ea);font-size:11px;width:12px;text-align:center} | |
| .forge-chip{font-family:'Share Tech Mono',monospace;font-size:10px;padding:2px 7px;border-radius:20px;background:rgba(255,255,255,.06);white-space:nowrap} | |
| .forge-chip.f-lucky{background:rgba(0,230,118,.14);color:var(--green,#00e676)} | |
| .forge-chip.f-unlucky{background:rgba(255,64,80,.14);color:var(--red,#ff5064)} | |
| .forge-chip.f-legend{background:rgba(255,176,32,.16);color:#ffb020} | |
| .forge-action-detail{padding:8px 12px 12px;border-top:1px solid rgba(255,255,255,.06)} | |
| .fdet-sec{margin:10px 0} | |
| .fdet-title{font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:1px;text-transform:uppercase;color:var(--text3,#8ea);margin-bottom:5px} | |
| .fdet-lv{font-size:9px;color:var(--text3,#8ea)} | |
| .fbar{display:flex;align-items:center;gap:8px;margin:2px 0;font-size:11.5px} | |
| .fbar-click{cursor:pointer;border-radius:5px;padding:1px 3px;margin:1px -3px} | |
| .fbar-click:hover{background:rgba(0,200,255,.10)} | |
| .fbar-l{flex:0 0 34%;max-width:34%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right} | |
| .fbar-track{flex:1;display:flex;align-items:center;height:18px;background:rgba(255,255,255,.05);border-radius:4px;overflow:hidden;min-width:0} | |
| .fbar-fill{display:flex;height:100%;border-radius:4px;overflow:hidden;min-width:0} | |
| .sbar-seg{height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;min-width:0} | |
| .sbar-num{font-size:10px;font-family:'Share Tech Mono',monospace;color:#fff;text-shadow:0 0 3px #000,0 1px 2px #000;white-space:nowrap;padding:0 1px} | |
| .fbar-after{flex:0 0 auto;margin-left:5px;font-size:10px;font-family:'Share Tech Mono',monospace;white-space:nowrap;color:var(--text3,#8ea)} | |
| .fbar-after b{font-weight:700} | |
| .fbar-go{flex:0 0 auto;color:var(--accent,#00c8ff);font-size:13px;font-weight:700;width:14px;text-align:center;opacity:.8} | |
| .fdwarf-levels{margin:2px 0 8px 6%;padding-left:10px;border-left:2px solid rgba(255,255,255,.1)} | |
| .forge-tbl{width:100%;border-collapse:collapse;font-size:11.5px;margin-top:4px} | |
| .forge-tbl th{text-align:left;font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:1px;text-transform:uppercase;color:var(--text3,#8ea);padding:3px 8px;border-bottom:1px solid rgba(255,255,255,.1)} | |
| .forge-tbl td{padding:4px 8px;border-bottom:1px solid rgba(255,255,255,.04)} | |
| .forge-tbl tbody tr:hover{background:rgba(255,255,255,.03)} | |
| .forge-note{font-size:9.5px;color:var(--text3,#8ea);margin-top:6px;line-height:1.4;font-style:italic} | |
| .forge-empty{padding:14px;text-align:center;color:var(--text3,#8ea);font-family:'Share Tech Mono',monospace;font-size:11px} | |
| .forge-subtabs{display:flex;gap:6px;margin-bottom:12px;border-bottom:1px solid rgba(255,255,255,.08);flex-wrap:wrap} | |
| .lb-topsubs{margin-bottom:14px} | |
| .forge-subtab{background:none;border:none;border-bottom:2px solid transparent;color:var(--text3,#8ea);padding:7px 14px;cursor:pointer;font-weight:600;font-size:13px} | |
| .forge-subtab.active{color:var(--accent,#00c8ff);border-bottom-color:var(--accent,#00c8ff)} | |
| .forge-controls{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;margin-bottom:10px} | |
| .forge-controls label{font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3,#8ea);display:flex;flex-direction:column;gap:3px} | |
| .forge-controls select,.forge-controls input{background:rgba(0,0,0,.3);border:1px solid rgba(255,255,255,.14);color:var(--text,#eef);border-radius:6px;padding:5px 8px;font-size:12px} | |
| .forge-controls select option{background:#12161c;color:#e8eef7} | |
| .forge-controls input{margin-left:auto;min-width:150px} | |
| .forge-lb-name{color:var(--accent,#00c8ff);cursor:pointer;font-weight:600}.forge-lb-name:hover{text-decoration:underline} | |
| .forge-rank{color:var(--text3,#8ea);font-family:'Share Tech Mono',monospace} | |
| .lb-count{display:flex;flex-direction:column;line-height:1.15}.lb-mini{font-size:9px;color:var(--text3,#8ea)} | |
| .lb-inline-box{background:rgba(0,0,0,.22);border:1px solid rgba(0,200,255,.2);border-radius:8px;padding:8px 12px;margin:2px 0} | |
| .lb-inline-head{font-weight:700;font-size:12px;margin-bottom:6px;display:flex;align-items:center;gap:8px} | |
| .flookup{background:rgba(0,200,255,.14);border:1px solid rgba(0,200,255,.35);color:var(--accent,#00c8ff);border-radius:5px;padding:2px 8px;cursor:pointer;font-size:11px;white-space:nowrap;font-weight:600} | |
| .flookup:hover{background:rgba(0,200,255,.24)} | |
| .forge-pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:10px} | |
| .forge-pager button{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);color:var(--text,#eef);border-radius:6px;padding:5px 12px;cursor:pointer;font-size:12px} | |
| .forge-pager button:disabled{opacity:.35;cursor:default} | |
| .forge-pager span{font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--text3,#8ea)} | |
| .forge-scroll{overflow-x:auto} | |
| .forge-items td,.forge-items th{text-align:center;white-space:nowrap} | |
| .forge-rlabel{text-align:left!important;font-weight:700}.frow,.fcol{cursor:pointer}.frow:hover,.fcol:hover{background:rgba(0,200,255,.14)} | |
| .forge-cell{cursor:pointer;padding:4px 6px!important;border-radius:4px;min-width:58px} | |
| .forge-cell:hover{background:rgba(0,200,255,.12)!important} | |
| .forge-cell-n{display:block;font-weight:800;font-size:13px}.forge-cell-rate{display:block;font-family:'Share Tech Mono',monospace;font-size:8px;color:var(--text3,#8ea)} | |
| .forge-cell-empty{color:rgba(255,255,255,.12);text-align:center} | |
| .forge-drill-head{margin:12px 0 6px;font-weight:700;font-size:13px;display:flex;align-items:center;gap:8px;flex-wrap:wrap} | |
| .fdh-sub{font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--text3,#8ea);font-weight:400} | |
| .fback{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:var(--text,#eef);border-radius:5px;padding:2px 9px;cursor:pointer;font-size:11px} | |
| .f-lucky{color:var(--green,#00e676)}.f-unlucky{color:var(--red,#ff5064)}.f-neutral{color:var(--text2,#cde)}.f-legend{color:#ffb020} | |
| .r-common{color:#9fb0c3}.r-rare{color:#4ea3ff}.r-epic{color:#b060ff}.r-legendary{color:#ffb020} | |
| .forge-combine-intro{font-size:11px;color:var(--text2,#cde);line-height:1.5;margin-bottom:10px} | |
| .forge-combine-go{white-space:nowrap} | |
| .forge-combine-toggle{background:rgba(0,200,255,.12);border:1px solid rgba(0,200,255,.3);color:var(--accent,#00c8ff);border-radius:14px;padding:3px 11px;cursor:pointer;font-size:11px;font-weight:600;white-space:nowrap} | |
| .forge-combine-toggle:hover{background:rgba(0,200,255,.22)} | |
| #ai-forge-combine-box{border:1px solid rgba(0,200,255,.18);border-radius:8px;padding:10px 12px;margin-bottom:12px;background:rgba(0,200,255,.03)} | |
| .forge-combine-names{font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--text3,#8ea);margin:2px 0 10px} | |
| #forge-totals-head{margin:-4px 0 10px} | |
| #forge-combine-luck{margin-bottom:10px}`; | |
| document.head.appendChild(s); | |
| } | |
| function injectSkeleton() { | |
| const sub = $("ai-forge-panel"); | |
| if (sub) sub.innerHTML = `<div class="ph"><h2>Forge & Action Stats</h2> | |
| <button class="forge-combine-toggle" onclick="forgeToggleCombineBox()" title="Combine the forge stats of several accounts (e.g. your alts)">+ combine accounts</button> | |
| <div id="ai-forge-luck" class="ai-forge-luck"></div></div> | |
| <div class="pb"> | |
| <div id="ai-forge-combine-box" style="display:none"> | |
| <div class="forge-combine-intro">Combine the forge & action stats of several accounts into one — e.g. your own alts. Names separated by commas.</div> | |
| <div class="forge-controls"> | |
| <input type="text" id="forge-combine-input" placeholder="Name1, Name2, Name3…" onkeydown="if(event.key==='Enter')loadForgeCombined()"> | |
| <button class="fback forge-combine-go" onclick="loadForgeCombined()">Combine ›</button></div> | |
| <div id="forge-combine-luck"></div> | |
| <div id="forge-combine-body"></div></div> | |
| <div id="ai-forge-body"></div></div>`; | |
| } | |
| // Combine-Box im Lookup auf/zu; beim Oeffnen den aktuellen Lookup-Namen vorbefuellen. | |
| function forgeToggleCombineBox() { | |
| const box = $("ai-forge-combine-box"); if (!box) return; | |
| const show = box.style.display === "none"; | |
| box.style.display = show ? "" : "none"; | |
| if (show) { | |
| const inp = $("forge-combine-input"); | |
| if (inp) { | |
| if (!inp.value) { const n = $("ai-name"); if (n && n.value) inp.value = n.value.trim(); } | |
| inp.focus(); | |
| } | |
| } | |
| } | |
| Object.assign(window, { | |
| loadForgeStats, forgeHideStats, forgeToggleDetail, forgeDwarfItem, forgeOnLeaderboardOpen, | |
| lbSubtab, loadForgeTab, forgeSubtab, loadForgeLeaderboard, forgeLbPage, forgeLbSearch, | |
| forgeLbClickName, forgeOpenPlayer, loadForgeItems, forgeDrillReset, forgeDrillBack: drillBack, | |
| forgeDrillItem, forgeDrillPlayers, loadForgeTotals, loadForgeCombined, forgeCombinedToggle, | |
| forgeToggleCombineBox, | |
| }); | |
| injectCSS(); injectSkeleton(); initBarMeasure(); | |
| })(); | |