oettaM123 Claude Opus 4.8 commited on
Commit
df91ec0
·
1 Parent(s): fb25ada

Backend: per-scan power/level/gold time-series for >=1 Qnt accounts

Browse files

Collect a precise (second-granular) time-series point every time an
account with >= 1 Qnt power (1e18) is scanned via the normal lookup /
leaderboard ingest, so the player-lookup tab can later draw power/level/
gold over time. Stored in a SEPARATE persistent store (/data/power_series
.json, own lock + own 120s save interval) so the 600 MB leaderboard
write path is untouched.

- Point format: [t_sec, level, power, gold, sword_type, shield_type].
Active sword/shield captured as the small game type-code (translatable
back via SWORD_TYPE_NAMES / SHIELD_TYPE_NAMES); -1 when unknown.
- Add sword_type/shield_type to the live lookup result.
- Throttle to <=1 point/min/account (SM_POWER_SERIES_MIN_GAP) and cap
per account (SM_POWER_SERIES_MAX_POINTS=20000); env-tunable.
- GET /power-series?username=|player_id= returns the series (or
under_1qnt=true) for the frontend.

Accounts under 1 Qnt store nothing (frontend shows "unter 1qnt").
Frontend graph to follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +217 -0
app.py CHANGED
@@ -141,6 +141,28 @@ SM_LEADERBOARD_QUERY_LIMIT = max(25, int(_os.getenv("SM_LEADERBOARD_QUERY_LIMIT"
141
  # und die Anzahl blieb bei 500k haengen. Jetzt 2 Mio (Headroom) + Eviction nach Power (unten).
142
  SM_LEADERBOARD_MAX_ROWS = max(1000, int(_os.getenv("SM_LEADERBOARD_MAX_ROWS", "2000000")))
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  def _env_truthy(name: str, default: bool = False) -> bool:
145
  value = _os.getenv(name)
146
  if value is None:
@@ -8206,6 +8228,151 @@ def _sm_lb_publish_rows(rows):
8206
  pass
8207
 
8208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8209
  def _sm_leaderboard_upsert_lookup_results(results, source: str = "lookup", save: bool = True):
8210
  updated = 0
8211
  changed = []
@@ -8236,6 +8403,8 @@ def _sm_leaderboard_upsert_lookup_results(results, source: str = "lookup", save:
8236
  state["updated_at"] = int(time.time() * 1000)
8237
  if save:
8238
  _sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL)
 
 
8239
  # Push changed rows to live (SSE) listeners, but only if someone is watching.
8240
  if changed and _sm_lb_subscriber_count():
8241
  try:
@@ -8273,6 +8442,8 @@ def _sm_leaderboard_ingest_rows(results, save: bool = True):
8273
  state["updated_at"] = int(time.time() * 1000)
8274
  if save:
8275
  _sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL)
 
 
8276
  if updated:
8277
  _SM_LB_DIRTY.set() # neue Daten -> Hintergrund-Warmer rechnet die Views neu
8278
  return updated
@@ -10274,6 +10445,11 @@ def _sm_inventory_result(username: str, inventory: dict):
10274
  if isinstance(sword, dict):
10275
  sword_name = _sm_gear_type_name(SWORD_TYPE_NAMES, SCHWERTER, sword.get("type"))
10276
  result["sword"] = sword_name or ""
 
 
 
 
 
10277
  try:
10278
  result["sword_level"] = max(1, int(sword.get("level") or 1))
10279
  except (TypeError, ValueError):
@@ -10286,6 +10462,10 @@ def _sm_inventory_result(username: str, inventory: dict):
10286
  if isinstance(shield, dict):
10287
  shield_name = _sm_gear_type_name(SHIELD_TYPE_NAMES, SCHILDE, shield.get("type"))
10288
  result["shield"] = shield_name or ""
 
 
 
 
10289
  try:
10290
  result["shield_level"] = max(1, int(shield.get("level") or 1))
10291
  except (TypeError, ValueError):
@@ -11963,6 +12143,43 @@ def discord_lookup(username: str, include_rank: bool = True):
11963
  return result
11964
 
11965
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11966
  @app.get("/live-rank")
11967
  def live_rank(username: str):
11968
  clean = str(username or "").strip()
 
141
  # und die Anzahl blieb bei 500k haengen. Jetzt 2 Mio (Headroom) + Eviction nach Power (unten).
142
  SM_LEADERBOARD_MAX_ROWS = max(1000, int(_os.getenv("SM_LEADERBOARD_MAX_ROWS", "2000000")))
143
 
144
+ # ── Power/Level/Gold Zeitreihe (pro Scan) ────────────────────────────────────────────────────
145
+ # Eigener, vom 600-MB-Leaderboard-Cache komplett GETRENNTER Store (eigene Datei, eigener Lock,
146
+ # eigenes kurzes Save-Intervall) -> beruehrt den kritischen Leaderboard-Schreibpfad NICHT.
147
+ # Nur Accounts mit >= 1 Qnt Power (1e18) bekommen Punkte; jeder Lookup/Ingest haengt (gedrosselt
148
+ # auf <=1 Punkt/Min/Account) einen SEKUNDENgenauen Punkt an. Punktformat (kompakt):
149
+ # [t_sec, level, power_float, gold_float, sword_type, shield_type]
150
+ # sword/shield = Spiel-Typ-Code (kleine Zahl) der AKTIVEN Waffe, -1 wenn unbekannt.
151
+ def _sm_resolve_power_series_path() -> Path:
152
+ configured = (_os.getenv("SM_POWER_SERIES_FILE") or "").strip()
153
+ if configured:
154
+ path = Path(configured)
155
+ return path if path.is_absolute() else APP_DIR / path
156
+ if _os.getenv("SPACE_ID"):
157
+ return Path("/data") / "power_series.json"
158
+ return APP_DIR / "power_series.json"
159
+
160
+ SM_POWER_SERIES_PATH = _sm_resolve_power_series_path()
161
+ SM_POWER_SERIES_MIN_POWER = float(_os.getenv("SM_POWER_SERIES_MIN_POWER", "1e18")) # 1 Qnt
162
+ SM_POWER_SERIES_MIN_GAP = max(0.0, float(_os.getenv("SM_POWER_SERIES_MIN_GAP", "60.0"))) # <=1 Punkt/Min/Account
163
+ SM_POWER_SERIES_MAX_POINTS = max(100, int(_os.getenv("SM_POWER_SERIES_MAX_POINTS", "20000"))) # Sicherheitsdeckel/Account
164
+ SM_POWER_SERIES_SAVE_INTERVAL = max(10.0, float(_os.getenv("SM_POWER_SERIES_SAVE_INTERVAL", "120.0")))
165
+
166
  def _env_truthy(name: str, default: bool = False) -> bool:
167
  value = _os.getenv(name)
168
  if value is None:
 
8228
  pass
8229
 
8230
 
8231
+ # ── Power/Level/Gold Zeitreihen-Store (separat, eigener Lock/Datei) ───────────────────────────
8232
+ _SM_PSERIES_LOCK = threading.RLock()
8233
+ _SM_PSERIES_STATE = None # {"v":1,"updated_at":ms,"series":{key:{"u":name,"p":[[...]]}}}
8234
+ _SM_PSERIES_DIRTY = False
8235
+ _SM_PSERIES_LAST_SAVE_MONOTONIC = 0.0
8236
+ _SM_PSERIES_FIELDS = ["t", "level", "power", "gold", "sword", "shield"]
8237
+
8238
+
8239
+ def _sm_pseries_read_file(path: Path):
8240
+ if not path.exists():
8241
+ return None
8242
+ blob = path.read_bytes()
8243
+ if blob[:2] == b"\x1f\x8b": # gzip-Magic
8244
+ blob = gzip.decompress(blob)
8245
+ data = json.loads(blob.decode("utf-8"))
8246
+ if isinstance(data, dict) and isinstance(data.get("series"), dict):
8247
+ return data
8248
+ return None
8249
+
8250
+
8251
+ def _sm_pseries_load_unlocked():
8252
+ global _SM_PSERIES_STATE
8253
+ if _SM_PSERIES_STATE is not None:
8254
+ return _SM_PSERIES_STATE
8255
+ state = {"v": 1, "updated_at": 0, "series": {}}
8256
+ try:
8257
+ data = _sm_pseries_read_file(SM_POWER_SERIES_PATH)
8258
+ if data:
8259
+ series = {}
8260
+ for key, entry in (data.get("series") or {}).items():
8261
+ if not isinstance(entry, dict) or not isinstance(entry.get("p"), list):
8262
+ continue
8263
+ series[str(key)] = {"u": str(entry.get("u") or ""), "p": entry.get("p")}
8264
+ state["series"] = series
8265
+ state["updated_at"] = int(data.get("updated_at") or 0)
8266
+ except Exception as exc: # noqa: BLE001
8267
+ print(f"[power-series load failed] {type(exc).__name__}: {str(exc)[:200]}")
8268
+ state = {"v": 1, "updated_at": 0, "series": {}}
8269
+ _SM_PSERIES_STATE = state
8270
+ return state
8271
+
8272
+
8273
+ def _sm_pseries_write_unlocked():
8274
+ state = _sm_pseries_load_unlocked()
8275
+ state["updated_at"] = int(time.time() * 1000)
8276
+ payload = json.dumps(state, ensure_ascii=True, separators=(",", ":")).encode("utf-8")
8277
+ SM_POWER_SERIES_PATH.parent.mkdir(parents=True, exist_ok=True)
8278
+ tmp = SM_POWER_SERIES_PATH.with_suffix(SM_POWER_SERIES_PATH.suffix + ".tmp")
8279
+ tmp.write_bytes(gzip.compress(payload, 4))
8280
+ tmp.replace(SM_POWER_SERIES_PATH)
8281
+
8282
+
8283
+ def _sm_pseries_save_unlocked(force: bool = False):
8284
+ """Throttled: schreibt nur alle SM_POWER_SERIES_SAVE_INTERVAL Sekunden (ausser force)."""
8285
+ global _SM_PSERIES_DIRTY, _SM_PSERIES_LAST_SAVE_MONOTONIC
8286
+ if not _SM_PSERIES_DIRTY and not force:
8287
+ return False
8288
+ now = time.monotonic()
8289
+ if not force and _SM_PSERIES_LAST_SAVE_MONOTONIC:
8290
+ if now - _SM_PSERIES_LAST_SAVE_MONOTONIC < SM_POWER_SERIES_SAVE_INTERVAL:
8291
+ return False
8292
+ try:
8293
+ _sm_pseries_write_unlocked()
8294
+ _SM_PSERIES_LAST_SAVE_MONOTONIC = now
8295
+ _SM_PSERIES_DIRTY = False
8296
+ return True
8297
+ except Exception as exc: # noqa: BLE001
8298
+ print(f"[power-series save failed] {type(exc).__name__}: {str(exc)[:200]}")
8299
+ return False
8300
+
8301
+
8302
+ def _sm_pseries_gear_type(value):
8303
+ try:
8304
+ return int(value)
8305
+ except (TypeError, ValueError):
8306
+ return -1
8307
+
8308
+
8309
+ def _sm_power_series_record(result: dict):
8310
+ """Haengt einen Zeitreihen-Punkt an, wenn der Account >= 1 Qnt Power hat. Billig: nur
8311
+ in-memory + throttled Datei-Write. Wird NACH dem Leaderboard-Lock pro Ergebnis aufgerufen
8312
+ (kein verschachtelter Lock)."""
8313
+ global _SM_PSERIES_DIRTY
8314
+ if not isinstance(result, dict) or not result.get("ok"):
8315
+ return
8316
+ power = _sm_float_sort_value(result.get("power_raw"))
8317
+ if power < SM_POWER_SERIES_MIN_POWER:
8318
+ return # unter 1 Qnt -> gar nicht speichern (Frontend zeigt "unter 1qnt")
8319
+ try:
8320
+ level = int(result.get("level"))
8321
+ except (TypeError, ValueError):
8322
+ return
8323
+ key = _sm_leaderboard_storage_key(result.get("username") or "", result.get("player_id") or "")
8324
+ if not key:
8325
+ return
8326
+ gold = _sm_float_sort_value(result.get("gold_raw"))
8327
+ sword = _sm_pseries_gear_type(result.get("sword_type"))
8328
+ shield = _sm_pseries_gear_type(result.get("shield_type"))
8329
+ now = int(time.time())
8330
+ with _SM_PSERIES_LOCK:
8331
+ state = _sm_pseries_load_unlocked()
8332
+ series = state["series"]
8333
+ entry = series.get(key)
8334
+ if entry is None:
8335
+ entry = {"u": str(result.get("username") or ""), "p": []}
8336
+ series[key] = entry
8337
+ else:
8338
+ uname = str(result.get("username") or "")
8339
+ if uname:
8340
+ entry["u"] = uname
8341
+ pts = entry["p"]
8342
+ if pts:
8343
+ try:
8344
+ if now - int(pts[-1][0]) < SM_POWER_SERIES_MIN_GAP:
8345
+ return # zu frisch -> Spam-Schutz (<=1 Punkt/Min/Account)
8346
+ except (TypeError, ValueError, IndexError):
8347
+ pass
8348
+ pts.append([now, level, power, gold, sword, shield])
8349
+ if len(pts) > SM_POWER_SERIES_MAX_POINTS:
8350
+ del pts[: len(pts) - SM_POWER_SERIES_MAX_POINTS]
8351
+ _SM_PSERIES_DIRTY = True
8352
+ _sm_pseries_save_unlocked(force=False)
8353
+
8354
+
8355
+ def _sm_power_series_record_many(results):
8356
+ for result in results or []:
8357
+ try:
8358
+ _sm_power_series_record(result)
8359
+ except Exception: # noqa: BLE001
8360
+ pass
8361
+
8362
+
8363
+ def _sm_power_series_flusher():
8364
+ while True:
8365
+ time.sleep(SM_POWER_SERIES_SAVE_INTERVAL)
8366
+ try:
8367
+ with _SM_PSERIES_LOCK:
8368
+ _sm_pseries_save_unlocked(force=False)
8369
+ except Exception: # noqa: BLE001
8370
+ pass
8371
+
8372
+
8373
+ threading.Thread(target=_sm_power_series_flusher, name="sm-power-series-flush", daemon=True).start()
8374
+
8375
+
8376
  def _sm_leaderboard_upsert_lookup_results(results, source: str = "lookup", save: bool = True):
8377
  updated = 0
8378
  changed = []
 
8403
  state["updated_at"] = int(time.time() * 1000)
8404
  if save:
8405
  _sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL)
8406
+ # Zeitreihe (>= 1 Qnt) ausserhalb des Leaderboard-Locks mitschreiben.
8407
+ _sm_power_series_record_many(results)
8408
  # Push changed rows to live (SSE) listeners, but only if someone is watching.
8409
  if changed and _sm_lb_subscriber_count():
8410
  try:
 
8442
  state["updated_at"] = int(time.time() * 1000)
8443
  if save:
8444
  _sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL)
8445
+ # Zeitreihe (>= 1 Qnt) ausserhalb des Leaderboard-Locks mitschreiben.
8446
+ _sm_power_series_record_many(results)
8447
  if updated:
8448
  _SM_LB_DIRTY.set() # neue Daten -> Hintergrund-Warmer rechnet die Views neu
8449
  return updated
 
10445
  if isinstance(sword, dict):
10446
  sword_name = _sm_gear_type_name(SWORD_TYPE_NAMES, SCHWERTER, sword.get("type"))
10447
  result["sword"] = sword_name or ""
10448
+ # Roher Spiel-Typ-Code der AKTIVEN Waffe (kleine Zahl) fuer die Zeitreihe/Translation.
10449
+ try:
10450
+ result["sword_type"] = int(sword.get("type"))
10451
+ except (TypeError, ValueError):
10452
+ pass
10453
  try:
10454
  result["sword_level"] = max(1, int(sword.get("level") or 1))
10455
  except (TypeError, ValueError):
 
10462
  if isinstance(shield, dict):
10463
  shield_name = _sm_gear_type_name(SHIELD_TYPE_NAMES, SCHILDE, shield.get("type"))
10464
  result["shield"] = shield_name or ""
10465
+ try:
10466
+ result["shield_type"] = int(shield.get("type"))
10467
+ except (TypeError, ValueError):
10468
+ pass
10469
  try:
10470
  result["shield_level"] = max(1, int(shield.get("level") or 1))
10471
  except (TypeError, ValueError):
 
12143
  return result
12144
 
12145
 
12146
+ @app.get("/power-series")
12147
+ def power_series(username: str = "", player_id: str = ""):
12148
+ """Power/Level/Gold-Zeitreihe eines Accounts (nur Accounts mit >= 1 Qnt Power).
12149
+ Bevorzugt player_id (exakter Match); username als Fallback. points = Liste von
12150
+ [t_sec, level, power, gold, sword_type, shield_type]. Fuer das Frontend-Diagramm."""
12151
+ key = _sm_leaderboard_storage_key(username or "", player_id or "")
12152
+ if not key:
12153
+ raise HTTPException(400, "username oder player_id erforderlich.")
12154
+ with _SM_PSERIES_LOCK:
12155
+ state = _sm_pseries_load_unlocked()
12156
+ series = state.get("series") or {}
12157
+ entry = series.get(key)
12158
+ # Fallback: nur Name angegeben, gespeichert ist aber unter id:<player_id>.
12159
+ if entry is None and username and not player_id:
12160
+ want = _discord_lookup_key(username)
12161
+ for cand_key, cand in series.items():
12162
+ if _discord_lookup_key(cand.get("u")) == want:
12163
+ entry, key = cand, cand_key
12164
+ break
12165
+ if entry is None:
12166
+ # Kein Datensatz -> entweder noch nie >= 1 Qnt gescannt, oder unter der Schwelle.
12167
+ return {"ok": True, "key": key, "under_1qnt": True, "count": 0,
12168
+ "fields": _SM_PSERIES_FIELDS, "min_power": SM_POWER_SERIES_MIN_POWER, "points": []}
12169
+ pts = list(entry.get("p") or [])
12170
+ uname = entry.get("u") or username
12171
+ return {
12172
+ "ok": True,
12173
+ "key": key,
12174
+ "username": uname,
12175
+ "under_1qnt": False,
12176
+ "min_power": SM_POWER_SERIES_MIN_POWER,
12177
+ "fields": _SM_PSERIES_FIELDS,
12178
+ "count": len(pts),
12179
+ "points": pts,
12180
+ }
12181
+
12182
+
12183
  @app.get("/live-rank")
12184
  def live_rank(username: str):
12185
  clean = str(username or "").strip()