"""Correlate RCON competitive windows with trusted persisted scoreboard matches.""" from __future__ import annotations import sqlite3 from datetime import datetime, timezone from pathlib import Path from .config import get_storage_path, use_postgres_rcon_storage from .normalizers import normalize_map_name from .scoreboard_origins import resolve_trusted_scoreboard_match_url from .sqlite_utils import connect_sqlite_readonly MIN_CONFIDENCE_SCORE = 5 MAX_CANDIDATES = 200 def resolve_rcon_scoreboard_match_url( *, server_slug: object, map_name: object, started_at: object, ended_at: object, duration_seconds: object = None, player_count: object = None, peak_players: object = None, allied_score: object = None, axis_score: object = None, db_path: Path | None = None, ) -> str | None: """Return a trusted scoreboard URL for an RCON window only on strong evidence.""" resolution = resolve_rcon_scoreboard_correlation( server_slug=server_slug, map_name=map_name, started_at=started_at, ended_at=ended_at, duration_seconds=duration_seconds, player_count=player_count, peak_players=peak_players, allied_score=allied_score, axis_score=axis_score, db_path=db_path, ) match_url = resolution.get("match_url") return str(match_url) if match_url else None def resolve_rcon_scoreboard_correlation( *, server_slug: object, map_name: object, started_at: object, ended_at: object, duration_seconds: object = None, player_count: object = None, peak_players: object = None, allied_score: object = None, axis_score: object = None, db_path: Path | None = None, ) -> dict[str, object]: """Return a safe candidate selection summary for one RCON match window.""" normalized_server_slug = str(server_slug or "").strip() normalized_map = normalize_map_name(map_name) rcon_start = _parse_timestamp(started_at) rcon_end = _parse_timestamp(ended_at) if not normalized_server_slug or not normalized_map or not rcon_start or not rcon_end: return {"match_url": None, "candidate_count": 0, "reason": "invalid-rcon-window"} if rcon_end < rcon_start: rcon_start, rcon_end = rcon_end, rcon_start candidates = _list_persisted_scoreboard_candidates( server_slug=normalized_server_slug, db_path=db_path or get_storage_path(), ) scored_candidates = [ scored for candidate in candidates if (scored := _score_candidate( candidate, normalized_map=normalized_map, rcon_start=rcon_start, rcon_end=rcon_end, duration_seconds=_coerce_int(duration_seconds), player_count=_coerce_int(player_count), peak_players=_coerce_int(peak_players), allied_score=_coerce_int(allied_score), axis_score=_coerce_int(axis_score), )) is not None ] if not scored_candidates: return { "match_url": None, "candidate_count": len(candidates), "reason": "no-safe-candidate", } scored_candidates.sort(key=lambda item: item["score"], reverse=True) best = scored_candidates[0] if int(best["score"]) < MIN_CONFIDENCE_SCORE: return { "match_url": None, "candidate_count": len(candidates), "reason": "low-confidence", } if len(scored_candidates) > 1 and int(scored_candidates[1]["score"]) >= int(best["score"]): return { "match_url": None, "candidate_count": len(candidates), "reason": "ambiguous-candidate", } return { "match_url": str(best["match_url"]), "candidate_count": len(candidates), "reason": "linked", "selected_candidate": { "external_match_id": best.get("external_match_id"), "correlation_score": int(best["score"]), }, } def diagnose_rcon_scoreboard_correlation( *, server_slug: object, map_name: object, started_at: object, ended_at: object, duration_seconds: object = None, player_count: object = None, peak_players: object = None, allied_score: object = None, axis_score: object = None, db_path: Path | None = None, ) -> dict[str, object]: """Describe safe candidate scoring for a single RCON correlation window.""" normalized_server_slug = str(server_slug or "").strip() normalized_map = normalize_map_name(map_name) rcon_start = _parse_timestamp(started_at) rcon_end = _parse_timestamp(ended_at) if not normalized_server_slug or not normalized_map or not rcon_start or not rcon_end: return { "candidate_search_window": { "started_at": started_at, "ended_at": ended_at, "candidate_limit": MAX_CANDIDATES, }, "candidate_count": 0, "top_candidates": [], "selected_candidate": None, "final_reason": "invalid-rcon-window", } if rcon_end < rcon_start: rcon_start, rcon_end = rcon_end, rcon_start candidates = _list_persisted_scoreboard_candidates( server_slug=normalized_server_slug, db_path=db_path or get_storage_path(), ) resolution = resolve_rcon_scoreboard_correlation( server_slug=server_slug, map_name=map_name, started_at=started_at, ended_at=ended_at, duration_seconds=duration_seconds, player_count=player_count, peak_players=peak_players, allied_score=allied_score, axis_score=axis_score, db_path=db_path, ) summaries = [ _diagnostic_candidate_summary( candidate, server_slug=normalized_server_slug, normalized_map=normalized_map, rcon_start=rcon_start, rcon_end=rcon_end, duration_seconds=_coerce_int(duration_seconds), player_count=_coerce_int(player_count), peak_players=_coerce_int(peak_players), allied_score=_coerce_int(allied_score), axis_score=_coerce_int(axis_score), ) for candidate in candidates ] summaries.sort( key=lambda item: ( -int(item["correlation_score"] or -1), str(item.get("external_match_id") or ""), ) ) selected_id = ( resolution.get("selected_candidate", {}).get("external_match_id") if isinstance(resolution.get("selected_candidate"), dict) else None ) selected_candidate = next( (item for item in summaries if item.get("external_match_id") == selected_id), None, ) return { "candidate_search_window": { "started_at": rcon_start.isoformat().replace("+00:00", "Z"), "ended_at": rcon_end.isoformat().replace("+00:00", "Z"), "candidate_limit": MAX_CANDIDATES, }, "candidate_count": len(candidates), "top_candidates": summaries[:5], "selected_candidate": selected_candidate, "final_reason": resolution["reason"], } def _list_persisted_scoreboard_candidates( *, server_slug: str, db_path: Path, ) -> list[dict[str, object]]: if use_postgres_rcon_storage(): from .postgres_rcon_storage import list_scoreboard_candidates postgres_candidates = list_scoreboard_candidates( server_slug=server_slug, limit=MAX_CANDIDATES, ) if postgres_candidates: return postgres_candidates try: with connect_sqlite_readonly(db_path) as connection: rows = connection.execute( """ SELECT historical_matches.external_match_id, historical_matches.started_at, historical_matches.ended_at, historical_matches.map_name, historical_matches.map_pretty_name, historical_matches.allied_score, historical_matches.axis_score, historical_matches.raw_payload_ref, historical_servers.slug AS server_slug, COUNT(historical_player_match_stats.id) AS player_count FROM historical_matches INNER JOIN historical_servers ON historical_servers.id = historical_matches.historical_server_id LEFT JOIN historical_player_match_stats ON historical_player_match_stats.historical_match_id = historical_matches.id WHERE historical_servers.slug = ? AND historical_matches.raw_payload_ref IS NOT NULL GROUP BY historical_matches.id ORDER BY COALESCE(historical_matches.ended_at, historical_matches.started_at) DESC LIMIT ? """, (server_slug, MAX_CANDIDATES), ).fetchall() except sqlite3.Error: return [] items: list[dict[str, object]] = [] for row in rows: match_url = resolve_trusted_scoreboard_match_url( row["raw_payload_ref"], row["server_slug"], ) if not match_url: continue items.append( { "external_match_id": row["external_match_id"], "started_at": row["started_at"], "ended_at": row["ended_at"], "map_name": row["map_name"], "map_pretty_name": row["map_pretty_name"], "allied_score": row["allied_score"], "axis_score": row["axis_score"], "player_count": row["player_count"], "match_url": match_url, } ) if items and use_postgres_rcon_storage(): from .postgres_rcon_storage import upsert_scoreboard_candidates upsert_scoreboard_candidates(server_slug=server_slug, candidates=items) return items def _score_candidate( candidate: dict[str, object], *, normalized_map: str, rcon_start: datetime, rcon_end: datetime, duration_seconds: int | None, player_count: int | None, peak_players: int | None, allied_score: int | None, axis_score: int | None, ) -> dict[str, object] | None: candidate_map = normalize_map_name( candidate.get("map_pretty_name") or candidate.get("map_name") ) if candidate_map != normalized_map: return None candidate_start = _parse_timestamp(candidate.get("started_at")) candidate_end = _parse_timestamp(candidate.get("ended_at")) if not candidate_start or not candidate_end: return None if candidate_end < candidate_start: candidate_start, candidate_end = candidate_end, candidate_start score = 0 overlap_seconds = _overlap_seconds(rcon_start, rcon_end, candidate_start, candidate_end) rcon_midpoint = rcon_start + (rcon_end - rcon_start) / 2 if overlap_seconds > 0: score += 3 if candidate_start <= rcon_midpoint <= candidate_end: score += 2 closest_edge_distance = min( abs((rcon_start - candidate_start).total_seconds()), abs((rcon_start - candidate_end).total_seconds()), abs((rcon_end - candidate_start).total_seconds()), abs((rcon_end - candidate_end).total_seconds()), ) if closest_edge_distance <= 1800: score += 2 elif closest_edge_distance <= 3600: score += 1 candidate_duration = int((candidate_end - candidate_start).total_seconds()) if duration_seconds and candidate_duration > 0: if abs(candidate_duration - duration_seconds) <= 1800: score += 1 elif overlap_seconds > 0 and duration_seconds <= candidate_duration: score += 1 candidate_allied_score = _coerce_int(candidate.get("allied_score")) candidate_axis_score = _coerce_int(candidate.get("axis_score")) if ( allied_score is not None and axis_score is not None and candidate_allied_score is not None and candidate_axis_score is not None ): if candidate_allied_score == allied_score and candidate_axis_score == axis_score: score += 2 elif sorted((candidate_allied_score, candidate_axis_score)) == sorted((allied_score, axis_score)): score += 1 candidate_players = _coerce_int(candidate.get("player_count")) reference_players = peak_players or player_count if candidate_players and reference_players: if abs(candidate_players - reference_players) <= 20: score += 1 elif candidate_players >= int(reference_players * 0.75): score += 1 if score <= 0: return None return { "score": score, "external_match_id": candidate.get("external_match_id"), "match_url": candidate["match_url"], } def _diagnostic_candidate_summary( candidate: dict[str, object], *, server_slug: str, normalized_map: str, rcon_start: datetime, rcon_end: datetime, duration_seconds: int | None, player_count: int | None, peak_players: int | None, allied_score: int | None, axis_score: int | None, ) -> dict[str, object]: match_url = resolve_trusted_scoreboard_match_url(candidate.get("match_url"), server_slug) safe_candidate = {**candidate, "match_url": match_url} if match_url else None scored = ( _score_candidate( safe_candidate, normalized_map=normalized_map, rcon_start=rcon_start, rcon_end=rcon_end, duration_seconds=duration_seconds, player_count=player_count, peak_players=peak_players, allied_score=allied_score, axis_score=axis_score, ) if safe_candidate else None ) map_label = candidate.get("map_pretty_name") or candidate.get("map_name") summary = { "external_match_id": candidate.get("external_match_id"), "started_at": candidate.get("started_at"), "ended_at": candidate.get("ended_at"), "map": map_label, "score": { "allied_score": _coerce_int(candidate.get("allied_score")), "axis_score": _coerce_int(candidate.get("axis_score")), }, "match_url": match_url, "correlation_score": int(scored["score"]) if scored else None, } if not match_url: summary["rejection_reason"] = "unsafe-url" elif scored is None: summary["rejection_reason"] = "map-or-window-mismatch" return summary def _overlap_seconds( first_start: datetime, first_end: datetime, second_start: datetime, second_end: datetime, ) -> int: return max(0, int((min(first_end, second_end) - max(first_start, second_start)).total_seconds())) def _parse_timestamp(value: object) -> datetime | None: if not isinstance(value, str) or not value.strip(): return None try: parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) except ValueError: return None if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return parsed.astimezone(timezone.utc) def _coerce_int(value: object) -> int | None: if value is None: return None try: return int(round(float(value))) except (TypeError, ValueError): return None