""" weekly_br_elo.py Window-scoped player & squadron ELO scoring. Python port of the computePerformanceScore + ratingCtes pipeline from server.js, used by the Weekly BR Report executor. The percentile distribution (benchmark) is built from the same in-window dataset, so scores represent ranking against peers who also played that week. """ # Standard Library Imports import logging import math from typing import Any, Dict, List, Optional, Tuple # Third-Party Library Imports import aiosqlite # Local Module Imports from .utils import SQ_BATTLES_DB_PATH # Score weights (mirror server.js:631 computePerformanceScore) _W_KDR = 0.32 _W_HEAVY_RATE = 0.23 _W_KILLS_PER_GAME = 0.14 _W_GAMES_LOG = 0.10 _W_WIN_RATE = 0.06 _W_ASSISTS_PER_GAME = 0.06 _W_CAPTURES_PER_GAME = 0.04 _W_DEATHS_PER_GAME = 0.05 # lower-is-better _METRIC_KEYS: Tuple[str, ...] = ( "kdr", "kills_per_game", "heavy_rate", "win_rate", "assists_per_game", "captures_per_game", "deaths_per_game", "games_log", ) def _rating_ctes(entity_expr: str) -> str: """Return the WITH-clause CTEs that produce per-session heavy scores. Direct port of server.js:647 ratingCtes(). The window predicate uses `endtime_unix BETWEEN ? AND ?` -- params order: (start_ts, end_ts). """ return f""" WITH player_sessions AS ( SELECT {entity_expr} AS entity_key, p.UID, p.session_id, SUM(p.ground_kills + p.air_kills) AS kills, SUM(p.assists) AS assists, SUM(p.captures) AS captures, SUM(p.deaths) AS deaths, MAX(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END) AS win FROM player_games_hist p WHERE p.UID IS NOT NULL AND p.nick NOT LIKE 'coop/%' AND p.endtime_unix >= ? AND p.endtime_unix <= ? GROUP BY entity_key, p.UID, p.session_id ), leader_stats AS ( SELECT ps.session_id, MAX(ps.kills) AS max_kills FROM player_sessions ps GROUP BY ps.session_id ), leader_counts AS ( SELECT ps.session_id, COUNT(*) AS top_count FROM player_sessions ps JOIN leader_stats ls ON ls.session_id = ps.session_id AND ls.max_kills = ps.kills GROUP BY ps.session_id ), second_stats AS ( SELECT ps.session_id, MAX(ps.kills) AS second_kills FROM player_sessions ps JOIN leader_stats ls ON ls.session_id = ps.session_id WHERE ps.kills < ls.max_kills GROUP BY ps.session_id ), scored_sessions AS ( SELECT ps.*, CASE WHEN ps.kills >= 3 AND ps.kills = ls.max_kills AND lc.top_count = 1 AND (ps.kills - COALESCE(ss.second_kills, 0)) >= 2 THEN MIN(1.0, (ps.kills - 2) / 2.0) WHEN ps.kills >= 3 AND ps.kills = ls.max_kills AND lc.top_count = 1 THEN 0.6 * MIN(1.0, (ps.kills - 2) / 2.0) ELSE 0 END AS heavy_score FROM player_sessions ps JOIN leader_stats ls ON ls.session_id = ps.session_id JOIN leader_counts lc ON lc.session_id = ps.session_id LEFT JOIN second_stats ss ON ss.session_id = ps.session_id ) """ async def _fetch_rows(sql: str, params: Tuple[Any, ...]) -> List[Dict[str, Any]]: """Run a read-only query against sq_battles.db and return list-of-dicts.""" async with aiosqlite.connect( f"file:{SQ_BATTLES_DB_PATH}?mode=ro", uri=True, timeout=30.0 ) as db: await db.execute("PRAGMA busy_timeout=30000;") async with db.execute(sql, params) as cursor: cols = [c[0] for c in (cursor.description or [])] rows = await cursor.fetchall() return [dict(zip(cols, r)) for r in rows] async def _query_player_aggregates( start_ts: int, end_ts: int ) -> List[Dict[str, Any]]: sql = _rating_ctes("p.UID") + """ SELECT entity_key AS uid, COUNT(*) AS games, SUM(kills) AS total_kills, SUM(assists) AS total_assists, SUM(captures) AS total_captures, SUM(deaths) AS total_deaths, SUM(win) AS wins, SUM(heavy_score) AS heavy_score FROM scored_sessions GROUP BY entity_key """ return await _fetch_rows(sql, (start_ts, end_ts)) async def _query_squadron_aggregates( start_ts: int, end_ts: int ) -> List[Dict[str, Any]]: sql = _rating_ctes("p.squadron_name") + """, squadron_sessions AS ( SELECT entity_key, session_id, SUM(kills) AS kills, SUM(assists) AS assists, SUM(captures) AS captures, SUM(deaths) AS deaths, MAX(win) AS win, SUM(heavy_score) AS heavy_score FROM scored_sessions WHERE entity_key IS NOT NULL AND entity_key != 'UNKNOWN' AND entity_key != '' GROUP BY entity_key, session_id ) SELECT entity_key AS squadron_name, COUNT(*) AS games, SUM(kills) AS total_kills, SUM(assists) AS total_assists, SUM(captures) AS total_captures, SUM(deaths) AS total_deaths, SUM(win) AS wins, SUM(heavy_score) AS heavy_score FROM squadron_sessions GROUP BY entity_key """ return await _fetch_rows(sql, (start_ts, end_ts)) async def _query_uid_to_meta( start_ts: int, end_ts: int ) -> Dict[str, Tuple[str, str]]: """Map UID -> (most-recent nick, most-recent squadron_name) within window.""" sql = """ SELECT p.UID AS uid, p.nick AS nick, p.squadron_name AS squadron_name, MAX(p.endtime_unix) AS last_seen FROM player_games_hist p WHERE p.UID IS NOT NULL AND p.nick NOT LIKE 'coop/%' AND p.endtime_unix >= ? AND p.endtime_unix <= ? GROUP BY p.UID """ rows = await _fetch_rows(sql, (start_ts, end_ts)) out: Dict[str, Tuple[str, str]] = {} for r in rows: uid = str(r.get("uid") or "") if not uid: continue out[uid] = (str(r.get("nick") or ""), str(r.get("squadron_name") or "")) return out def _normalize_rating_stats(row: Dict[str, Any]) -> Dict[str, float]: games = max(0, int(row.get("games") or 0)) kills = max(0, int(row.get("total_kills") or 0)) deaths = max(0, int(row.get("total_deaths") or 0)) assists = max(0, int(row.get("total_assists") or 0)) captures = max(0, int(row.get("total_captures") or 0)) wins = max(0, int(row.get("wins") or 0)) heavy = max(0.0, float(row.get("heavy_score") or 0)) return { "games": float(games), "kdr": (kills / deaths) if deaths > 0 else float(kills), "kills_per_game": (kills / games) if games > 0 else 0.0, "heavy_rate": (heavy / games) if games > 0 else 0.0, "win_rate": (wins / games * 100.0) if games > 0 else 0.0, "assists_per_game": (assists / games) if games > 0 else 0.0, "captures_per_game": (captures / games) if games > 0 else 0.0, "deaths_per_game": (deaths / games) if games > 0 else 0.0, "games_log": math.log1p(games), } def _build_benchmark(rows: List[Dict[str, Any]]) -> Dict[str, List[float]]: """Build sorted percentile distributions for each scoring metric.""" normalized = [_normalize_rating_stats(r) for r in rows] return {m: sorted(float(n[m]) for n in normalized) for m in _METRIC_KEYS} def _upper_bound(values: List[float], v: float) -> int: lo, hi = 0, len(values) while lo < hi: mid = (lo + hi) // 2 if values[mid] <= v: lo = mid + 1 else: hi = mid return lo def _lower_bound(values: List[float], v: float) -> int: lo, hi = 0, len(values) while lo < hi: mid = (lo + hi) // 2 if values[mid] < v: lo = mid + 1 else: hi = mid return lo def _metric_percentile( value: float, values: List[float], lower_is_better: bool = False ) -> float: if not values: return 0.0 if lower_is_better: return (len(values) - _lower_bound(values, value)) / len(values) return _upper_bound(values, value) / len(values) def _compute_performance_score( row: Dict[str, Any], benchmark: Dict[str, List[float]] ) -> float: s = _normalize_rating_stats(row) weighted = ( _W_KDR * _metric_percentile(s["kdr"], benchmark["kdr"]) + _W_HEAVY_RATE * _metric_percentile(s["heavy_rate"], benchmark["heavy_rate"]) + _W_KILLS_PER_GAME * _metric_percentile(s["kills_per_game"], benchmark["kills_per_game"]) + _W_GAMES_LOG * _metric_percentile(s["games_log"], benchmark["games_log"]) + _W_WIN_RATE * _metric_percentile(s["win_rate"], benchmark["win_rate"]) + _W_ASSISTS_PER_GAME * _metric_percentile(s["assists_per_game"], benchmark["assists_per_game"]) + _W_CAPTURES_PER_GAME * _metric_percentile(s["captures_per_game"], benchmark["captures_per_game"]) + _W_DEATHS_PER_GAME * _metric_percentile( s["deaths_per_game"], benchmark["deaths_per_game"], lower_is_better=True ) ) sample = max(0.0, min(1.0, math.sqrt(s["games"] / 10.0))) return round(max(0.0, min(5.0, 5.0 * weighted * sample)), 2) async def player_scores(start_ts: int, end_ts: int) -> Dict[str, Dict[str, Any]]: """Return {uid: {nick, squadron_name, score, games, kdr, win_rate, kills_per_game}}.""" rows = await _query_player_aggregates(start_ts, end_ts) if not rows: return {} benchmark = _build_benchmark(rows) meta = await _query_uid_to_meta(start_ts, end_ts) out: Dict[str, Dict[str, Any]] = {} for r in rows: uid = str(r.get("uid") or "") if not uid: continue norm = _normalize_rating_stats(r) score = _compute_performance_score(r, benchmark) nick, squadron = meta.get(uid, ("", "")) out[uid] = { "uid": uid, "nick": nick, "squadron_name": squadron, "score": score, "games": int(norm["games"]), "kdr": norm["kdr"], "win_rate": norm["win_rate"], "kills_per_game": norm["kills_per_game"], } return out async def squadron_scores(start_ts: int, end_ts: int) -> Dict[str, Dict[str, Any]]: """Return {squadron_name: {score, games, kdr, win_rate, kills_per_game}}.""" rows = await _query_squadron_aggregates(start_ts, end_ts) if not rows: return {} benchmark = _build_benchmark(rows) out: Dict[str, Dict[str, Any]] = {} for r in rows: sq = str(r.get("squadron_name") or "") if not sq: continue norm = _normalize_rating_stats(r) score = _compute_performance_score(r, benchmark) out[sq] = { "squadron_name": sq, "score": score, "games": int(norm["games"]), "kdr": norm["kdr"], "win_rate": norm["win_rate"], "kills_per_game": norm["kills_per_game"], } return out def top_n_squadrons_with_top_k_players_from_maps( sq_map: Dict[str, Dict[str, Any]], pl_map: Dict[str, Dict[str, Any]], n: int = 20, k: int = 5, ) -> List[Dict[str, Any]]: """Sync helper: top N squadrons + top K players from already-computed maps. Lets a caller pay the cost of `squadron_scores`/`player_scores` once and then synthesize multiple report payloads (wildcard + per-squadron) from the same data without re-hitting the DB. """ ranked = sorted( sq_map.values(), key=lambda r: (-float(r["score"]), -int(r["games"]), str(r["squadron_name"])), )[:n] by_squadron: Dict[str, List[Dict[str, Any]]] = {} for p in pl_map.values(): s = str(p.get("squadron_name") or "") if not s: continue by_squadron.setdefault(s, []).append(p) payload: List[Dict[str, Any]] = [] for r in ranked: s = str(r["squadron_name"]) roster = sorted( by_squadron.get(s, []), key=lambda p: (-float(p["score"]), -int(p["games"]), str(p.get("nick") or "")), )[:k] payload.append({**r, "top_players": roster}) return payload def squadron_report_for_variants_from_maps( sq_map: Dict[str, Dict[str, Any]], pl_map: Dict[str, Dict[str, Any]], name_variants: List[str], k: int = 15, ) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]: """Sync helper: per-squadron payload from already-computed maps.""" sq_row: Optional[Dict[str, Any]] = None for v in name_variants: if not v: continue if v in sq_map: sq_row = sq_map[v] break variant_set = {v for v in name_variants if v} roster = [p for p in pl_map.values() if str(p.get("squadron_name") or "") in variant_set] roster.sort( key=lambda p: (-float(p["score"]), -int(p["games"]), str(p.get("nick") or "")) ) return sq_row, roster[:k] async def top_n_squadrons_with_top_k_players( start_ts: int, end_ts: int, n: int = 20, k: int = 5 ) -> List[Dict[str, Any]]: """Wildcard-mode payload: top N squadrons by score, each with their top K players. Convenience wrapper that fetches the maps then calls the `_from_maps` helper. Prefer the explicit two-step (compute maps once, call from-maps multiple times) when building reports for many subscribers. """ sq_map = await squadron_scores(start_ts, end_ts) pl_map = await player_scores(start_ts, end_ts) return top_n_squadrons_with_top_k_players_from_maps(sq_map, pl_map, n=n, k=k) async def squadron_report_for_variants( start_ts: int, end_ts: int, name_variants: List[str], k: int = 15, ) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]: """Per-squadron mode payload: best-matching squadron row + its top K players. Tries name_variants in order against the squadron name appearing in player_games_hist (long_name, tag_name, short_name). Returns (None, []) if no variant matched and no players in window carry any of the variants. """ sq_map = await squadron_scores(start_ts, end_ts) pl_map = await player_scores(start_ts, end_ts) return squadron_report_for_variants_from_maps(sq_map, pl_map, name_variants, k=k) def br_color_int(max_br: float) -> int: """Map a BR value to an embed sidebar color (matches /schedule color bands).""" if max_br >= 13.0: return 0xE74C3C if max_br >= 10.0: return 0xE67E22 if max_br >= 7.0: return 0xF1C40F if max_br >= 4.0: return 0x2ECC71 return 0x3498DB __all__ = [ "player_scores", "squadron_scores", "top_n_squadrons_with_top_k_players", "top_n_squadrons_with_top_k_players_from_maps", "squadron_report_for_variants", "squadron_report_for_variants_from_maps", "br_color_int", ]