swordmasters-optimizer / forge_api.py
oettaM123's picture
Claude Opus 4.8
Forge: nightmare/arena split, totals+dwarf, combine-in-lookup, VIP, formulas
f4f6fd8
Raw History Blame Contribute Delete
38.1 kB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
forge_api.py — Forge-/Aktions-Statistik-Endpoints fuer den SwordMasters-Optimizer.
Liest die aggregierte SQLite-DB (privates HF-Dataset oettaM123/swordmasters-forge-stats).
GET /forge/meta globale Totals + Aktionsliste + Update-Zeit
GET /forge/player?name= Pro-Aktion-Zusammenfassung + Luck (nur dwarf)
GET /forge/player-detail?name=&action= Drill-Down, gruppiert nach dim (item/ore/enchant/level/drop/...)
GET /forge/leaderboard?action=&sort=&q= Leaderboard (bei Suche mit Rang der Treffer)
GET /forge/items?action=dwarf Raritaet x Level-Raster (bucket_totals, exakt)
GET /forge/item-cell?rarity=&level= Items einer Dwarf-Zelle (Erfolge) + Zellen-Total
GET /forge/item-leaderboard?item=&level= Spieler, die dieses Item auf dem Level am meisten forgen
GET /forge/breakdown?action=&dim= Globaler Breakdown (Ore/Enchant/Drop/...) aus item_totals
Alle luck/value nur fuer dwarf (nur dort exakte erwartete Rate). Nur lesend.
DB-Quelle: env FORGE_DB_PATH -> ./forge_stats.db -> HF-Dataset (Token env HF_TOKEN).
"""
import json
import math
import os
import sqlite3
import threading
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/forge", tags=["forge"])
FORGE_REPO = os.getenv("FORGE_DATASET_REPO", "oettaM123/swordmasters-forge-stats")
FORGE_DB_FILE = "forge_stats.db"
ACTION_LABEL = {
"dwarf": "Dwarf Forge", "enchant": "Enchant", "smelt": "Smelt",
"downgrade": "Downgrade", "elf": "Elf", "arena": "Arena", "pet_enhance": "Pet Enhance",
"nightmare": "Nightmare Arena",
}
ACTION_ORDER = ["dwarf", "enchant", "smelt", "downgrade", "elf", "pet_enhance", "arena", "nightmare"]
# Aktionen ohne sinnvolle Fail-/Rate-Anzeige (Fails werden nicht geloggt). Arena + Nightmare
# Arena loggen nur Drops (keine Fails) -> beide drop-basiert, Luck = Legendary-Ratio.
NO_RATE = {"arena", "nightmare"}
# Drop-basierte Aktionen: Leaderboard zeigt Legendary statt Luck-z, Luck = Legendary-Drops.
DROP_ACTIONS = {"arena", "nightmare"}
# Aktionen mit exakter erwarteter Rate -> Luck-Sortierung/Bewertung moeglich
HAS_LUCK = {"dwarf"}
HAS_VALUE = {"dwarf"}
# Mindest-Versuche fuer die Luck-Sortierung je Aktion (kleinvolumige Aktionen brauchen
# einen niedrigeren Floor, sonst ist das Luckiest-Board leer). Bei Namenssuche ignoriert.
LUCK_MIN = {"dwarf": 200, "elf": 40, "pet_enhance": 40, "smelt": 100,
"enchant": 80, "downgrade": 50, "arena": 30, "nightmare": 20}
# Aktionen, die in die kombinierte "Lucky/Unlucky"-Gesamtwertung eines Spielers eingehen.
OVERALL_LUCK_ACTIONS = ("dwarf", "elf", "pet_enhance", "arena")
_DB = {"conn": None, "err": None, "path": None, "checked": 0.0}
_LOCK = threading.Lock()
# Forge-DB periodisch nachladen: der taegliche Upload erzeugt eine neue Dataset-Revision ->
# hf_hub_download liefert dann einen neuen Pfad. Ohne das zeigte der Space die Forge-Stats bis zum
# naechsten Space-Neustart veraltet (die Connection war prozesslang gecacht). 0 = aus.
_DB_RELOAD_TTL = float(os.getenv("FORGE_DB_RELOAD_TTL", "1800")) # 30 min
_RELOADING = {"busy": False}
_META_CACHE = {"data": None}
def _luck_z(successes, expected, attempts):
if not expected or not attempts:
return 0.0
var = expected * (1.0 - expected / attempts)
return (successes - expected) / math.sqrt(var) if var > 0 else 0.0
def _norm_cdf(z):
return 0.5 * (1.0 + math.erf(z / math.sqrt(2.0)))
# ── Live-Luck pro Spieler: Prozent-Luck je Aktion + Overall-Value (dwarf/elf/pet) ──
# Alles zur Laufzeit aus player_detail + gebuendeltem Preis-Snapshot -> kein DB-Rebuild.
ELF_OUTPUT = {"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"}
PET_OUTPUT = {"peppy": "XPeppy", "blue axolotl": "XAxolotl", "pengoo": "XPengoo",
"leo": "XLeo", "phoenix": "XPhoenix", "octoo": "XOctoo", "xen": "XXen",
"inferno": "XInferno", "runix": "XRunix"}
_VALUE_ACTIONS = ("dwarf", "elf", "pet_enhance") # Overall-Wert nur aus diesen
_STD_DIM = {"smelt": "ore", "enchant": "enchant", "downgrade": "level", "pet_enhance": "pet"}
_VAL_DIM = {"dwarf": "item", "elf": "input", "pet_enhance": "pet"}
_FORGE_PRICES = {"data": None}
_BUCKET_RATES = {"data": None}
def _forge_prices():
if _FORGE_PRICES["data"] is None:
try:
_FORGE_PRICES["data"] = json.loads(
(Path(__file__).with_name("forge_prices.json")).read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
_FORGE_PRICES["data"] = {"items": {}, "pets": {}}
return _FORGE_PRICES["data"]
def _item_price(name, level):
it = _forge_prices().get("items", {})
nl = str(name or "").lower()
for cls in ("sword", "shield"): # Namen sind pro Klasse eindeutig
v = it.get(f"{cls}|{nl}|{int(level)}")
if v:
return v
return None
def _pet_price(name):
return _forge_prices().get("pets", {}).get(str(name or "").lower())
def _bucket_rates(c):
"""Globale Erfolgsraten je Bucket (dwarf: rarity×level; smelt/enchant/downgrade/pet:
je Ore/Typ/Level/Pet; arena: Legendary-Rate) aus item_totals/bucket_totals, gecacht."""
if _BUCKET_RATES["data"] is None:
R = {"dwarf": {}, "smelt": {}, "enchant": {}, "downgrade": {}, "pet_enhance": {}, "arena_leg": 0.0}
for r in c.execute("SELECT rarity, level, SUM(attempts) a, SUM(successes) s "
"FROM bucket_totals GROUP BY rarity, level"):
if r["a"]:
R["dwarf"][(r["rarity"], r["level"])] = r["s"] / r["a"]
for act, dim in (("smelt", "ore"), ("downgrade", "level"), ("elf", "input"),
("pet_enhance", "pet"), ("enchant", "enchant")):
agg = {}
for r in c.execute("SELECT items.name k, SUM(attempts) a, SUM(successes) s "
"FROM item_totals JOIN items ON items.id = item_totals.key_id "
"WHERE action=? AND dim=? GROUP BY items.name", (act, dim)):
if r["a"]:
agg[str(r["k"] or "").lower()] = r["s"] / r["a"]
R[act] = agg
r = c.execute("SELECT SUM(CASE WHEN rarity='Legendary' THEN successes ELSE 0 END) leg, "
"SUM(attempts) a FROM item_totals WHERE action='arena' AND dim='drop'").fetchone()
R["arena_leg"] = (r["leg"] / r["a"]) if r and r["a"] and r["leg"] else 0.0
_BUCKET_RATES["data"] = R
return _BUCKET_RATES["data"]
def _compute_player_luck(c, pids, actions):
"""pids = alle lokalen player_ids des Accounts (ueber Namenswechsel aggregiert).
Setzt je Aktion `luck_pct` (Prozent ueber/unter Erwartung) + `luck_percentile` (Farbe),
mit exakten Einzel-Wahrscheinlichkeiten (dwarf/elf: Σ angezeigtes luck% inkl. Fortune;
smelt/enchant/downgrade/pet: je Bucket-Rate; arena: erwartete Legendarys). Berechnet das
Overall aus dwarf+elf+pet als WERT-Luck (realisierter vs. erwarteter Wert; Wert = Input
immer verloren, Output nur bei Erfolg: dwarf->Item L+1, elf->Legendary L1, pet->XPet)."""
R = _bucket_rates(c)
pids = list(pids) if not isinstance(pids, (list, tuple)) else list(pids)
if not pids:
return None
qmarks = ",".join("?" * len(pids))
buckets = {} # (action, dim) -> [(key_lower, level, rarity, att, succ), ...]
for r in c.execute("SELECT action, dim, items.name k, level, rarity, attempts, successes "
"FROM player_detail JOIN items ON items.id = player_detail.key_id "
f"WHERE player_id IN ({qmarks})", pids):
buckets.setdefault((r["action"], r["dim"]), []).append(
(str(r["k"] or "").lower(), r["level"] or 0, r["rarity"] or "", r["attempts"] or 0, r["successes"] or 0))
by_action = {a["action"]: a for a in actions}
def _std_expected(act):
rate = R.get(act, {})
return sum(att * rate.get(k, 0.0) for k, lv, rr, att, su in buckets.get((act, _STD_DIM[act]), []))
# --- per-Aktion Prozent-Luck NUR fuer dwarf/elf/pet ---
# (smelt/enchant/downgrade: Odds haengen an free/paid bzw. Spieler-Level -> nicht im Log,
# also kein sauberes Luck; arena nur als Legendary-Count. Bewusst weggelassen.)
for a in actions:
act = a["action"]
if act not in _VALUE_ACTIONS:
continue
att = a.get("attempts") or 0; su = a.get("successes") or 0
exp = (a.get("expected") or 0.0) if act in ("dwarf", "elf") else _std_expected("pet_enhance")
if exp and exp > 0:
a["luck_pct"] = (su - exp) / exp * 100.0
a["luck_percentile"] = _norm_cdf(_luck_z(su, exp, att))
# --- Overall = WERT-Luck aus dwarf/elf/pet ---
num = var = 0.0
parts = []
for act in _VALUE_ACTIONS:
rows = buckets.get((act, _VAL_DIM[act]), [])
if not rows:
continue
prate = R["dwarf"] if act == "dwarf" else R.get(act, {})
def _p(k, lv, rr):
return prate.get((rr, lv), 0.0) if act == "dwarf" else prate.get(k, 0.0)
# Fortune-Skalierung: erwartete Erfolge auf das exakte Σluck% des Spielers ziehen
factor = 1.0
if act in ("dwarf", "elf"):
base = sum(att * _p(k, lv, rr) for k, lv, rr, att, su in rows)
aexp = by_action.get(act, {}).get("expected") or 0.0
if base > 0 and aexp > 0:
factor = aexp / base
anum = avar = 0.0
for k, lv, rr, att, su in rows:
p = _p(k, lv, rr) * factor
if p <= 0.0 or p >= 1.0:
continue
if act == "dwarf":
swing = _item_price(k, lv + 1)
elif act == "elf":
out = ELF_OUTPUT.get(k); swing = _item_price(out, 1) if out else None
else:
out = PET_OUTPUT.get(k); swing = _pet_price(out) if out else None
if not swing:
continue
anum += swing * (su - att * p)
avar += swing * swing * att * p * (1.0 - p)
if avar > 0:
num += anum; var += avar; parts.append(act)
av = by_action.get(act) # Value pro Aktion (dwarf/elf/pet) fuer die Anzeige
if av is not None:
av["value_luck_coins"] = anum
av["value_luck_percentile"] = _norm_cdf(anum / math.sqrt(avar))
if var <= 0:
return None
z = num / math.sqrt(var)
return {"luck_z": z, "luck_percentile": _norm_cdf(z), "value_luck_coins": num,
"parts": parts, "weighting": "value"}
_PA_HAS_VLUCK = {"v": None}
def _has_vluck(c):
"""Hat der aktuelle DB-Stand die Wert-Luck-Spalten? (Abwaertskompatibel zu alten DBs.)"""
if _PA_HAS_VLUCK["v"] is None:
cols = {r[1] for r in c.execute("PRAGMA table_info(player_action)")}
_PA_HAS_VLUCK["v"] = "vluck_num" in cols
return _PA_HAS_VLUCK["v"]
_LEG_RATE = {} # action -> globale Legendary-Droprate (Legendary-Drops / alle Drops)
def _leg_rate(c, action="arena"):
"""Globale Legendary-Droprate einer drop-basierten Aktion (arena/nightmare)."""
if action not in _LEG_RATE:
leg = c.execute("SELECT SUM(successes) s FROM item_totals "
"WHERE action=? AND rarity='Legendary'", (action,)).fetchone()["s"] or 0
att = c.execute("SELECT SUM(attempts) a FROM item_totals "
"WHERE action=?", (action,)).fetchone()["a"] or 0
_LEG_RATE[action] = (leg / att) if att else 0.0
return _LEG_RATE[action]
def _arena_leg_rate(c):
"""Rueckwaerts-kompatibler Alias (Arena-Legendary-Rate)."""
return _leg_rate(c, "arena")
def _combined_overall(c, actions):
"""Kombinierte Lucky/Unlucky-Wertung — WERT-gewichtet ueber dwarf+elf+pet_enhance+arena.
Z = Σ vluck_num / √(Σ vluck_var), mit Einsatz = Wert-at-Risk pro Event (dwarf: Item@Level,
elf: 10×Input@L5-Epic, pet: 10×Normal-Pet, arena: Ø Legendary-Wert). So zaehlt ein teurer
Elf-/L9-Dwarf-Fail schwer und billiger Kram ist Rauschen — statt statistisch gleicher Pools.
Faellt auf die alte count-basierte Pool-Wertung zurueck, solange die DB keine vluck-Spalten hat."""
num = 0.0
var = 0.0
ev = 0
parts = []
for a in actions:
act = a["action"]
if act not in OVERALL_LUCK_ACTIONS:
continue
vv = a.get("vluck_var") or 0.0
if vv <= 0:
continue
num += a.get("vluck_num") or 0.0
var += vv
ev += a.get("attempts") or 0
parts.append(act)
if var > 0:
z = num / math.sqrt(var)
return {"luck_z": z, "luck_percentile": _norm_cdf(z), "events": ev, "parts": parts,
"value_luck_coins": num, "weighting": "value"}
return _combined_overall_count(c, actions)
def _combined_overall_count(c, actions):
"""Alt/Fallback: statistisch gleich gewichteter Pool-z-Score (kein Wert)."""
arate = _arena_leg_rate(c)
num = 0.0
var = 0.0
ev = 0
parts = []
for a in actions:
act = a["action"]
att = a.get("attempts") or 0
if not att or act not in OVERALL_LUCK_ACTIONS:
continue
if act == "arena":
succ = a.get("legendary") or 0
exp = att * arate
v = exp * (1.0 - arate)
else:
exp = a.get("expected") or 0.0
succ = a.get("successes") or 0
v = exp * (1.0 - exp / att) if att else 0.0
if exp <= 0 or v <= 0:
continue
num += (succ - exp)
var += v
ev += att
parts.append(act)
if var <= 0:
return None
z = num / math.sqrt(var)
return {"luck_z": z, "luck_percentile": _norm_cdf(z), "events": ev, "parts": parts,
"weighting": "count"}
_RATE_MAP = {"data": None}
def _rate_map(c):
"""(rarity, level) -> exakte Dwarf-Erfolgsrate (aus bucket_totals, beide Klassen)."""
if _RATE_MAP["data"] is None:
m = {}
for r in c.execute("SELECT rarity, level, SUM(attempts) a, SUM(successes) s "
"FROM bucket_totals GROUP BY rarity, level"):
if r["a"]:
m[(r["rarity"], r["level"])] = r["s"] / r["a"]
_RATE_MAP["data"] = m
return _RATE_MAP["data"]
def _est_attempts(rm, rarity, level, successes):
"""Geschaetzte Forge-VERSUCHE eines Items = Erfolge / (fixe Rate je rarity,level).
Dwarf-Burns sind namentlich korrupt, aber die Rate ist je (rarity,level) exakt."""
rate = rm.get((rarity, level))
return int(round(successes / rate)) if rate and rate > 0 else successes
def _resolve_db_path():
p = os.getenv("FORGE_DB_PATH")
if p and Path(p).exists():
return p
local = Path(__file__).with_name(FORGE_DB_FILE)
if local.exists():
return str(local)
from huggingface_hub import hf_hub_download
token = (os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")
or os.getenv("HUGGINGFACE_TOKEN"))
return hf_hub_download(repo_id=FORGE_REPO, repo_type="dataset",
filename=FORGE_DB_FILE, token=token)
def _open_db(path):
c = sqlite3.connect(f"file:{path}?mode=ro", uri=True, check_same_thread=False)
c.row_factory = sqlite3.Row
c.create_function("luck_z", 3, _luck_z, deterministic=True)
return c
def _invalidate_db_caches():
"""Alle aus der Forge-DB abgeleiteten Modul-Caches verwerfen (nach einem DB-Tausch).
Lazy: der naechste Zugriff fuellt sie aus der neuen DB neu. (forge_prices ist DATEI-basiert
-> bleibt.)"""
_META_CACHE["data"] = None
_BUCKET_RATES["data"] = None
_RATE_MAP["data"] = None
_PA_HAS_VLUCK["v"] = None
_LEG_RATE.clear()
def _reload_worker():
"""Im HINTERGRUND: neue DB-Version holen (falls vorhanden) und Connection ATOMAR tauschen.
Kein Request blockiert -- bis zum Tausch wird die alte DB weiter bedient. Die alte Connection
schliesst Python per Refcount, sobald kein laufender Request sie mehr haelt (nie mitten im
Query -> kein close-Race). Fehler sind nie fatal (alte DB laeuft weiter)."""
try:
newpath = _resolve_db_path()
if newpath and newpath != _DB["path"]:
newc = _open_db(newpath)
_DB["conn"] = newc # atomarer Pointer-Tausch (GIL)
_DB["path"] = newpath
_invalidate_db_caches()
except Exception: # noqa: BLE001
pass
finally:
_RELOADING["busy"] = False
def _conn():
c = _DB["conn"]
if c is not None:
now = time.monotonic()
# periodisch pruefen, ob eine neue DB-Version vorliegt; den Download macht EIN
# Hintergrund-Thread, Requests servieren derweil die alte DB (kein Blockieren).
if (_DB_RELOAD_TTL > 0 and not _RELOADING["busy"]
and (now - _DB["checked"]) >= _DB_RELOAD_TTL and _LOCK.acquire(blocking=False)):
try:
if not _RELOADING["busy"] and (time.monotonic() - _DB["checked"]) >= _DB_RELOAD_TTL:
_RELOADING["busy"] = True
_DB["checked"] = time.monotonic()
threading.Thread(target=_reload_worker, daemon=True).start()
finally:
_LOCK.release()
return _DB["conn"]
with _LOCK:
if _DB["conn"] is None and _DB["err"] is None:
try:
path = _resolve_db_path()
_DB["conn"] = _open_db(path)
_DB["path"] = path
_DB["checked"] = time.monotonic()
except Exception as exc: # noqa: BLE001
_DB["err"] = f"{type(exc).__name__}: {exc}"
if _DB["conn"] is None:
raise HTTPException(503, f"Forge stats DB unavailable ({_DB['err']}).")
return _DB["conn"]
def warm():
def _run():
try:
_conn()
except Exception: # noqa: BLE001
pass
threading.Thread(target=_run, daemon=True).start()
def _find_player(c, name):
row = c.execute("SELECT id, name FROM players WHERE name = ?", (name,)).fetchone()
if row:
return row["id"], row["name"]
row = c.execute("SELECT id, name FROM players WHERE name = ? COLLATE NOCASE", (name,)).fetchone()
return (row["id"], row["name"]) if row else (None, None)
# ── id-gebundene Aggregation ueber Namenswechsel ────────────────────────────────────────────────
# Die Forge-DB ist nach NAME gekeyt (player_id = kompakter Namens-Index). Um Stats wie die
# Power-Series an den ACCOUNT zu binden, loest app.py (das das Leaderboard + aliases hat) einen
# Namen -> alle Namen des Accounts (aktuell + fruehere/aliases) auf; wir aggregieren dann ueber
# alle lokalen player_ids dieser Namen. So bleiben die Forge-Stats nach einer Umbenennung beim
# selben Account. Kein Resolver gesetzt / Name unbekannt -> Fallback auf den einen Namen.
_ACCOUNT_NAMES_RESOLVER = None
def set_account_names_resolver(fn):
global _ACCOUNT_NAMES_RESOLVER
_ACCOUNT_NAMES_RESOLVER = fn
def _account_names(name):
names = []
if _ACCOUNT_NAMES_RESOLVER is not None:
try:
names = list(_ACCOUNT_NAMES_RESOLVER(name) or [])
except Exception: # noqa: BLE001
names = []
if name:
names.insert(0, name)
seen, out = set(), []
for n in names:
n = (n or "").strip()
if n and n.lower() not in seen:
seen.add(n.lower())
out.append(n)
return out
def _find_players(c, name):
"""Alle lokalen player_ids des Accounts (ueber alle Namen/aliases) + Anzeigename."""
pids, canonical = [], None
for nm in _account_names(name):
pid, canon = _find_player(c, nm)
if pid is not None and pid not in pids:
pids.append(pid)
if canonical is None:
canonical = canon
return pids, canonical
def _split_names(names):
"""'a, b; c' -> ['a','b','c'] (Komma/Semikolon-getrennt, getrimmt, leere raus)."""
return [n.strip() for n in str(names or "").replace(";", ",").split(",") if n.strip()]
def _find_players_multi(c, names_list):
"""Union der lokalen player_ids ueber MEHRERE Accounts (jeder inkl. seiner Aliases) —
fuer die kombinierte Mehr-Account-Ansicht. Gibt (pids, [canonical display names])."""
pids, labels, seen = [], [], set()
for raw in names_list[:20]: # Deckel gegen Missbrauch
sub, canon = _find_players(c, raw)
for pid in sub:
if pid not in seen:
seen.add(pid)
pids.append(pid)
if canon and canon not in labels:
labels.append(canon)
return pids, labels
def _summary_row(r):
"""player_action-Zeile -> Anzeige-dict (rate, luck, value je nach Aktion).
Luck fuer ALLE Aktionen: dwarf/elf via per-Event-luck, smelt/enchant/downgrade/
pet via globaler Rate (expected=attempts*rate), arena via Legendary-Ratio
(expected-Slot enthaelt die Legendary-Drop-Anzahl)."""
a = dict(r)
act = a["action"]
att = a.get("attempts") or 0
su = a.get("successes") or 0
exp = a.get("expected") if "expected" in a.keys() else a.get("expected_successes")
exp = exp or 0.0
a["label"] = ACTION_LABEL.get(act, act)
a["has_rate"] = act not in NO_RATE
a["rate"] = (su / att) if (att and a["has_rate"]) else None
a["fails"] = (att - su) if a["has_rate"] else 0
if act in DROP_ACTIONS:
a["legendary"] = int(round(exp))
a["legendary_ratio"] = (exp / att) if att else None
elif exp and att:
z = _luck_z(su, exp, att)
a["expected_rate"] = exp / att
a["luck_delta"] = su - exp
a["luck_z"] = z
a["luck_percentile"] = _norm_cdf(z)
if act in HAS_VALUE:
a["value_net"] = a.get("value_net")
return a
@router.get("/meta")
def forge_meta():
if _META_CACHE["data"] is not None:
return _META_CACHE["data"]
c = _conn()
meta = {r["key"]: r["value"] for r in c.execute("SELECT key, value FROM meta")}
seen, totals = set(), []
at = {}
if meta.get("action_totals"):
try:
at = json.loads(meta["action_totals"])
except Exception: # noqa: BLE001
at = {}
for action, pair in at.items():
seen.add(action)
totals.append({"action": action, "label": ACTION_LABEL.get(action, action),
"attempts": pair[0], "successes": pair[1],
"has_rate": action not in NO_RATE})
totals.sort(key=lambda t: (ACTION_ORDER.index(t["action"])
if t["action"] in ACTION_ORDER else 99))
players = int(meta.get("compact_players") or 0) or \
c.execute("SELECT COUNT(*) n FROM players").fetchone()["n"]
result = {"meta": meta, "totals": totals, "player_count": players,
"actions": [a for a in ACTION_ORDER if a in seen],
"built_at_unix": int(meta.get("built_at_unix") or 0),
"data_through": meta.get("data_through", "")}
_META_CACHE["data"] = result
return result
@router.get("/player")
def forge_player(name: str = "", names: str = ""):
"""Ein Account (name=) ODER mehrere kombiniert (names=a,b,c -> Dwarf-Multi: Stats ueber
alle genannten Accounts + deren Aliases zusammengerechnet)."""
c = _conn()
nlist = _split_names(names)
if nlist:
pids, labels = _find_players_multi(c, nlist)
canonical = ", ".join(labels) if labels else ", ".join(nlist)
if not pids:
raise HTTPException(404, "No forge stats logged for these accounts.")
else:
name = (name or "").strip()
if not name:
raise HTTPException(400, "Missing name.")
pids, canonical = _find_players(c, name)
if not pids:
raise HTTPException(404, f"No forge stats logged for '{name}'.")
# player_action hat 1 Zeile je (player_id, action) -> ueber alle Account-pids je Aktion summieren.
_SUM = ("attempts", "successes", "expected", "value_gained", "value_lost", "value_known", "value_net")
qmarks = ",".join("?" * len(pids))
agg = {}
for r in c.execute(
"SELECT action, attempts, successes, expected, value_gained, value_lost, "
f"value_known, value_net FROM player_action WHERE player_id IN ({qmarks})", pids):
act = r["action"]
cur = agg.get(act)
if cur is None:
agg[act] = {k: (r[k] if k in r.keys() else None) for k in r.keys()}
else:
for k in _SUM:
if k in r.keys():
cur[k] = (cur.get(k) or 0) + (r[k] or 0)
actions = [_summary_row(a) for a in agg.values()]
actions.sort(key=lambda a: (ACTION_ORDER.index(a["action"])
if a["action"] in ACTION_ORDER else 99))
try:
overall = _compute_player_luck(c, pids, actions) # Prozent-Luck je Aktion + Overall-Wert
except Exception: # noqa: BLE001 -> Anzeige darf nie am Luck haengen
overall = None
return {"name": canonical, "actions": actions, "overall": overall}
@router.get("/player-detail")
def forge_player_detail(name: str = "", action: str = "", limit: int = 400, names: str = ""):
c = _conn()
nlist = _split_names(names)
if nlist:
pids, labels = _find_players_multi(c, nlist)
canonical = ", ".join(labels) if labels else ", ".join(nlist)
if not pids:
raise HTTPException(404, "No forge stats logged for these accounts.")
else:
name = (name or "").strip()
pids, canonical = _find_players(c, name)
if not pids:
raise HTTPException(404, f"No forge stats logged for '{name}'.")
limit = max(1, min(int(limit), 2000))
qmarks = ",".join("?" * len(pids))
# Ueber alle Account-pids ziehen und gleiche (dim,key,level,rarity)-Buckets summieren; das
# SQL-LIMIT erst NACH dem Mergen je dim anwenden (sonst wuerde es vor der Aggregation schneiden).
merged = {}
for r in c.execute(
"SELECT dim, items.name AS key, level, rarity, attempts, successes, extra "
"FROM player_detail JOIN items ON items.id = player_detail.key_id "
f"WHERE player_id IN ({qmarks}) AND action = ? AND items.name <> 'Empty' COLLATE NOCASE",
pids + [action]):
d = dict(r)
k = (d["dim"], d["key"], d["level"], d["rarity"])
m = merged.get(k)
if m is None:
merged[k] = d
else:
for col in ("attempts", "successes", "extra"):
m[col] = (m.get(col) or 0) + (d.get(col) or 0)
dims = {}
for d in merged.values():
dims.setdefault(d.pop("dim"), []).append(d)
for dim in dims:
dims[dim].sort(key=lambda x: (-(x.get("successes") or 0), -(x.get("attempts") or 0)))
dims[dim] = dims[dim][:limit]
return {"name": canonical, "action": action,
"label": ACTION_LABEL.get(action, action), "dims": dims}
def _leaderboard_rank(c, action, sort, expr, direction, min_attempts, row):
"""1-basierter Rang der Zeile in der ungefilterten sortierten Liste."""
att = row["attempts"] or 0
exp = row["expected"] or 0
if sort in ("luckiest", "unluckiest"):
if action in DROP_ACTIONS: # Ratio Legendary/Versuch (exp = Legendary-Anzahl)
luckval = (exp / att) if att else 0.0
else:
luckval = _luck_z(row["successes"], exp, att)
else:
luckval = None
val = {"attempts": att, "gain": row["value_net"], "loss": row["value_net"],
"luckiest": luckval, "unluckiest": luckval, "legendary": exp}.get(sort)
if val is None:
return None
# Rang = 1 + Anzahl der "besseren" Zeilen. DESC (hoechster oben) -> besser = groesser.
cmp = ">" if direction == "DESC" else "<"
where = ["action = ?"]
params = [action]
if min_attempts > 0:
where.append("attempts >= ?"); params.append(min_attempts)
where.append(f"{expr} {cmp} ?"); params.append(val)
n = c.execute(f"SELECT COUNT(*) n FROM player_action WHERE {' AND '.join(where)}",
params).fetchone()["n"]
return n + 1
@router.get("/leaderboard")
def forge_leaderboard(action: str = "dwarf", sort: str = "attempts",
q: str = "", offset: int = 0, limit: int = 25,
min_attempts: int = 0):
c = _conn()
action = (action or "dwarf").strip()
limit = max(1, min(int(limit), 200))
offset = max(0, int(offset))
luck_sorts = {"luckiest": "DESC", "unluckiest": "ASC"}
if sort in ("gain", "loss") and action not in HAS_VALUE:
sort = "attempts"
if sort == "legendary" and action not in DROP_ACTIONS: # "Most legendarys" nur fuer arena/nightmare
sort = "attempts"
qq = q.strip()
if sort in luck_sorts:
direction = luck_sorts[sort]
if action in DROP_ACTIONS:
# Luckiest = hoechste Legendary/Versuch-Ratio (expected = Legendary-Anzahl). Damit steht
# JEDER mit >=1 Legendary ueber jedem mit 0, und "mehr Legendarys in weniger Versuchen"
# gewinnt. KEIN Mindest-Versuche-Floor -> alle Finder sichtbar, 0-Legendary landen unten.
expr = "(expected*1.0/attempts)"
else: # z-Score gegen erwartete Rate
expr = "luck_z(successes, expected, attempts)"
if min_attempts <= 0:
min_attempts = LUCK_MIN.get(action, 100)
elif sort == "legendary":
# expected traegt bei drop-Aktionen die Legendary-Anzahl -> nach den meisten Legendarys.
expr, direction = "expected", "DESC"
elif sort in ("gain", "loss"):
expr = "value_net"
direction = "ASC" if sort == "loss" else "DESC"
else:
expr, direction, sort = "attempts", "DESC", "attempts"
# Bei Namenssuche den Luck-Floor ignorieren, damit der gesuchte Name IMMER auftaucht
# (der Rang wird trotzdem gegen ALLE Spieler dieser Aktion berechnet).
if qq:
min_attempts = 0
where = ["pa.action = ?"]
params = [action]
if min_attempts > 0:
where.append("pa.attempts >= ?"); params.append(int(min_attempts))
if qq:
where.append("p.name LIKE ? ESCAPE '\\'")
params.append("%" + qq.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%")
# Tiebreak bei gleicher Ratio (drop-Aktionen): luckiest -> mehr Legendarys zuerst,
# unluckiest -> mehr Versuche zuerst (viele Versuche, 0 Legendary = am ungluecklichsten).
order_extra = ""
if action in DROP_ACTIONS and sort == "luckiest":
order_extra = ", pa.expected DESC"
elif action in DROP_ACTIONS and sort == "unluckiest":
order_extra = ", pa.attempts DESC"
sql = (f"SELECT p.name, pa.action, pa.attempts, pa.successes, pa.expected, "
f"pa.value_gained, pa.value_lost, pa.value_known, pa.value_net "
f"FROM player_action pa JOIN players p ON p.id = pa.player_id "
f"WHERE {' AND '.join(where)} ORDER BY {expr} {direction}{order_extra} LIMIT ? OFFSET ?")
rows = []
for r in c.execute(sql, params + [limit + 1, offset]):
d = _summary_row(r)
if qq: # bei Suche: echten Rang in der Gesamtliste zeigen
d["rank"] = _leaderboard_rank(c, action, sort, expr, direction, min_attempts, r)
rows.append(d)
has_more = len(rows) > limit
rows = rows[:limit]
return {"action": action, "sort": sort, "offset": offset, "has_more": has_more,
"min_attempts": min_attempts, "has_rate": action not in NO_RATE,
"has_luck": True, "is_arena": action in DROP_ACTIONS,
"has_value": action in HAS_VALUE, "rows": rows}
@router.get("/items")
def forge_items(action: str = "dwarf"):
c = _conn()
grid = [dict(r) for r in c.execute(
"SELECT item_class, level, rarity, attempts, successes FROM bucket_totals ORDER BY level")]
return {"action": "dwarf", "grid": grid}
@router.get("/item-dist")
def forge_item_dist(rarity: str = "", level: int = -1, offset: int = 0, limit: int = 60):
"""Dwarf-Items nach echten VERSUCHEN (attempts=forged+burned, exakt). Filter optional
nach rarity und/oder level: keine=Overview, nur rarity=Zeile, nur level=Spalte, beide=Zelle."""
c = _conn()
limit = max(1, min(int(limit), 1000)); offset = max(0, int(offset))
# 'Empty' ist kein echtes Katalog-Item (Log-Artefakt) -> ausblenden.
where = ["action='dwarf'", "dim='item'", "items.name <> 'Empty' COLLATE NOCASE"]
params = []
if rarity:
where.append("rarity = ?"); params.append(rarity)
if level >= 0:
where.append("level = ?"); params.append(int(level))
agg = {}
for r in c.execute(
"SELECT items.name AS item, rarity, attempts, successes FROM item_totals "
"JOIN items ON items.id = item_totals.key_id "
f"WHERE {' AND '.join(where)}", params):
e = agg.setdefault(r["item"], {"item": r["item"], "successes": 0, "attempts": 0, "_rar": {}})
e["successes"] += r["successes"]; e["attempts"] += r["attempts"]
if r["rarity"]: # DOMINANTE Raritaet (nach Versuchen), nicht die erste —
e["_rar"][r["rarity"]] = e["_rar"].get(r["rarity"], 0) + r["attempts"] # sonst filtert der
for e in agg.values(): # Drill auf eine 1x-fehlklassifizierte Minderheit (Darkblade-Bug)
e["rarity"] = max(e["_rar"], key=e["_rar"].get) if e["_rar"] else ""
e.pop("_rar", None)
items = sorted(agg.values(), key=lambda x: -x["attempts"])
total = len(items)
page = items[offset:offset + limit]
bw, bp = [], []
if rarity:
bw.append("rarity = ?"); bp.append(rarity)
if level >= 0:
bw.append("level = ?"); bp.append(int(level))
bt = c.execute("SELECT SUM(attempts) a, SUM(successes) s FROM bucket_totals"
+ (" WHERE " + " AND ".join(bw) if bw else ""), bp).fetchone()
return {"rarity": rarity, "level": level, "offset": offset, "total": total,
"has_more": offset + limit < total,
"tot_attempts": (bt["a"] or 0) if bt else 0,
"tot_successes": (bt["s"] or 0) if bt else 0, "items": page}
@router.get("/item-levels")
def forge_item_levels(item: str, rarity: str = ""):
"""Pro Level fuer ein Dwarf-Item: echte Versuche/Fails/Erfolge (exakt)."""
c = _conn()
it = c.execute("SELECT id FROM items WHERE name = ?", (item,)).fetchone()
if not it:
return {"item": item, "levels": []}
where = ["action='dwarf'", "dim='item'", "key_id = ?"]
params = [it["id"]]
if rarity:
where.append("rarity = ?"); params.append(rarity)
out = []
for r in c.execute(f"SELECT level, rarity, attempts, successes FROM item_totals "
f"WHERE {' AND '.join(where)} ORDER BY level", params):
out.append({"level": r["level"], "rarity": r["rarity"], "successes": r["successes"],
"attempts": r["attempts"], "fails": max(0, r["attempts"] - r["successes"])})
return {"item": item, "levels": out}
@router.get("/item-leaderboard")
def forge_item_leaderboard(item: str, level: int, rarity: str = "", action: str = "dwarf",
offset: int = 0, limit: int = 50):
"""Spieler, die dieses (Item, Level[, rarity]) am meisten forgen — echte Versuche.
Raritaets-Filter verhindert, dass 1x fehlklassifizierte Items die falschen Forger zeigen."""
c = _conn()
limit = max(1, min(int(limit), 500)); offset = max(0, int(offset))
it = c.execute("SELECT id FROM items WHERE name = ?", (item,)).fetchone()
if not it:
return {"item": item, "level": level, "rows": [], "has_more": False,
"tot": {"attempts": 0, "successes": 0}}
where = ["pd.action = ?", "pd.dim = 'item'", "pd.key_id = ?", "pd.level = ?"]
params = [action, it["id"], level]
if rarity:
where.append("pd.rarity = ?"); params.append(rarity)
rows = []
for r in c.execute(
"SELECT p.name, pd.attempts, pd.successes FROM player_detail pd "
"JOIN players p ON p.id = pd.player_id "
f"WHERE {' AND '.join(where)} ORDER BY pd.attempts DESC LIMIT ? OFFSET ?",
params + [limit + 1, offset]):
rows.append({"name": r["name"], "successes": r["successes"], "attempts": r["attempts"]})
has_more = len(rows) > limit
# Exaktes Gesamt-Total (auch fuer Klein-Accounts, die nicht einzeln gespeichert sind)
tw = ["action = ?", "dim = 'item'", "key_id = ?", "level = ?"]
tp = [action, it["id"], level]
if rarity:
tw.append("rarity = ?"); tp.append(rarity)
tr = c.execute(f"SELECT SUM(attempts) a, SUM(successes) s FROM item_totals "
f"WHERE {' AND '.join(tw)}", tp).fetchone()
tot = {"attempts": (tr["a"] or 0) if tr else 0, "successes": (tr["s"] or 0) if tr else 0}
return {"item": item, "level": level, "rarity": rarity, "action": action,
"offset": offset, "has_more": has_more, "rows": rows[:limit], "tot": tot}
@router.get("/breakdown")
def forge_breakdown(action: str, dim: str, limit: int = 100):
"""Globaler Breakdown aus item_totals (z.B. alle Ores, alle Enchant-Typen, alle Drops)."""
c = _conn()
limit = max(1, min(int(limit), 500))
rows = [dict(r) for r in c.execute(
"SELECT items.name AS key, level, rarity, item_class, attempts, successes "
"FROM item_totals JOIN items ON items.id = item_totals.key_id "
"WHERE action = ? AND dim = ? ORDER BY successes DESC, attempts DESC LIMIT ?",
(action, dim, limit))]
return {"action": action, "dim": dim, "rows": rows}