add SREBOT, SHARED, TSSBOT contents (fixup for #1223)
PR #1223 only staged the deletions of the old paths because the new top-level directories were still untracked when the commit was authored. This commit adds the actual restructured tree: SREBOT/ (existing bot), SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES, update_game_files), and TSSBOT/ (skeleton). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
"""
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user