Download forge_api.py from oettaM123/swordmasters-optimizer: direct link, hf CLI and curl.
- Browser
- Download file 38.1 kB
-
https://huggingface.co/spaces/oettaM123/swordmasters-optimizer/resolve/main/forge_api.py
- Command line
-
hf download hf://spaces/oettaM123/swordmasters-optimizer/forge_api.py
-
curl -L -o forge_api.py https://huggingface.co/spaces/oettaM123/swordmasters-optimizer/resolve/main/forge_api.py
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 | |
| 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 | |
| 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} | |
| 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 | |
| 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} | |
| 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} | |
| 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} | |
| 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} | |
| 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} | |
| 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} | |