"""Read-only minimal HTTP model over prospective RCON historical persistence.""" from __future__ import annotations import json from datetime import datetime, timedelta, timezone from .historical_storage import ALL_SERVERS_SLUG from .normalizers import normalize_map_name from .player_external_profiles import build_external_player_profile_fields from .rcon_scoreboard_correlation import resolve_rcon_scoreboard_match_url from .rcon_historical_storage import ( find_rcon_historical_competitive_window, get_rcon_historical_competitive_window_by_session, list_rcon_historical_competitive_summary_rows, list_rcon_historical_competitive_windows, ) MATCH_RESULT_SOURCE = "admin-log-match-ended" SESSION_RESULT_SOURCE = "rcon-session" def list_rcon_historical_server_summaries( *, server_key: str | None = None, ) -> list[dict[str, object]]: """Return per-target coverage and freshness from RCON-backed competitive storage.""" items = list_rcon_historical_competitive_summary_rows() if server_key and server_key != ALL_SERVERS_SLUG: normalized = server_key.strip() items = [ item for item in items if item["target_key"] == normalized or item["external_server_id"] == normalized ] summaries = [_build_server_summary(item) for item in items] if server_key == ALL_SERVERS_SLUG: return [_build_all_servers_summary(summaries)] return summaries def list_rcon_historical_recent_activity( *, server_key: str | None = None, limit: int = 20, ) -> list[dict[str, object]]: """Return recent RCON-backed competitive windows for one or all targets.""" from .rcon_admin_log_materialization import list_materialized_rcon_matches normalized_server_key = None if server_key == ALL_SERVERS_SLUG else server_key materialized_items = list_materialized_rcon_matches( target_key=normalized_server_key, only_ended=True, limit=limit, ) primary_items = [_build_materialized_recent_item(item) for item in materialized_items] if primary_items: return primary_items[:limit] session_items = list_rcon_historical_competitive_windows( target_key=normalized_server_key, limit=limit, ) fallback_items = [ { "server": { "slug": item["target_key"], "name": item["display_name"], "external_server_id": item["external_server_id"], "region": item["region"], }, "match_id": item["session_key"], "internal_detail_match_id": item["session_key"], "started_at": item["first_seen_at"], "ended_at": item["last_seen_at"], "closed_at": item["last_seen_at"], "map": { "name": item.get("map_name"), "pretty_name": normalize_map_name(item.get("map_pretty_name") or item.get("map_name")), }, "result": _build_rcon_result(item.get("latest_payload")), "gamestate": _build_rcon_gamestate(item.get("latest_payload")), "player_count": int(round(float(item.get("average_players") or 0))), "peak_players": item.get("peak_players"), "sample_count": item.get("sample_count"), "duration_seconds": item.get("duration_seconds"), "capture_basis": "rcon-competitive-window", "result_source": SESSION_RESULT_SOURCE, "capabilities": item.get("capabilities"), "minutes_since_capture": _minutes_since_timestamp(item.get("last_seen_at")), } for item in session_items ] return _merge_recent_items(primary_items, fallback_items, limit=limit) def describe_rcon_historical_read_model() -> dict[str, object]: """Describe what the minimal RCON historical read model currently supports.""" return { "source": "rcon-historical-competitive-read-model", "supported_endpoints": [ "/api/historical/server-summary", "/api/historical/recent-matches", ], "unsupported_endpoints": [ "/api/historical/weekly-top-kills", "/api/historical/weekly-leaderboard", "/api/historical/leaderboard", "/api/historical/monthly-mvp", "/api/historical/monthly-mvp-v2", "/api/historical/elo-mmr/leaderboard", "/api/historical/elo-mmr/player", "/api/historical/player-events", "/api/historical/player-profile", "/api/historical/snapshots/*", ], "capabilities": { "server_summary": "exact", "recent_matches": "exact-when-admin-log-match-ended", "competitive_quality": "partial", "result": "admin-log-match-ended", "gamestate": "session-fallback", "player_stats": "admin-log-derived", }, "limitations": [ "No retroactive backfill of closed matches.", "No weekly or monthly competitive leaderboards.", "No MVP or player-event parity with public-scoreboard.", "No player-level scoreboard parity from RCON samples alone.", ], } def get_rcon_historical_competitive_match_context( *, server_key: str, ended_at: str | None, map_name: str | None = None, ) -> dict[str, object] | None: """Return the closest RCON-backed competitive context for one historical match.""" return find_rcon_historical_competitive_window( server_key=server_key, ended_at=ended_at, map_name=map_name, ) def get_rcon_historical_match_detail( *, server_key: str, match_id: str, ) -> dict[str, object] | None: """Return one RCON competitive window as a match-detail compatible payload.""" from .rcon_admin_log_materialization import get_materialized_rcon_match_detail materialized = get_materialized_rcon_match_detail(server_key=server_key, match_key=match_id) if materialized is not None: return _build_materialized_detail_item(materialized) item = get_rcon_historical_competitive_window_by_session( server_key=server_key, session_key=match_id, ) if item is None: return None player_count = int(round(float(item.get("average_players") or 0))) server_slug = item["external_server_id"] or item["target_key"] return { "server": { "slug": item["target_key"], "name": item["display_name"], "external_server_id": item["external_server_id"], "region": item["region"], }, "match_id": item["session_key"], "started_at": item["first_seen_at"], "ended_at": item["last_seen_at"], "closed_at": item["last_seen_at"], "duration_seconds": item.get("duration_seconds"), "map": { "name": item.get("map_name"), "pretty_name": normalize_map_name(item.get("map_pretty_name") or item.get("map_name")), }, "result": _build_rcon_result(item.get("latest_payload")), "gamestate": _build_rcon_gamestate(item.get("latest_payload")), "player_count": int(round(float(item.get("average_players") or 0))), "peak_players": item.get("peak_players"), "sample_count": item.get("sample_count"), "players": [], "capture_basis": "rcon-competitive-window", "confidence": item.get("confidence_mode"), "source_basis": "rcon-session", "result_source": SESSION_RESULT_SOURCE, "capabilities": item.get("capabilities"), "match_url": resolve_rcon_scoreboard_match_url( server_slug=server_slug, map_name=item.get("map_pretty_name") or item.get("map_name"), started_at=item["first_seen_at"], ended_at=item["last_seen_at"], duration_seconds=item.get("duration_seconds"), player_count=player_count, peak_players=item.get("peak_players"), ), } def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object]: timestamps = _build_materialized_timestamp_payload(item) player_count = _resolve_materialized_player_count(item) scoreboard_correlation = build_materialized_scoreboard_correlation_input(item) return { "server": { "slug": item.get("target_key"), "name": _server_display_name(item.get("external_server_id") or item.get("target_key")), "external_server_id": item.get("external_server_id"), "region": None, }, "match_id": item.get("match_key"), "internal_detail_match_id": item.get("match_key"), "started_at": timestamps["started_at"], "ended_at": timestamps["ended_at"], "closed_at": timestamps["closed_at"], "timestamp_confidence": timestamps["timestamp_confidence"], "map": { "name": item.get("map_name"), "pretty_name": item.get("map_pretty_name") or normalize_map_name(item.get("map_name")), }, "game_mode": item.get("game_mode"), "result": { "allied_score": item.get("allied_score"), "axis_score": item.get("axis_score"), "winner": item.get("winner"), }, "winner": item.get("winner"), "player_count": player_count, "peak_players": None, "sample_count": None, "duration_seconds": _calculate_match_duration_seconds(item), "capture_basis": "rcon-materialized-admin-log", "confidence": item.get("confidence_mode"), "source_basis": item.get("source_basis"), "result_source": ( MATCH_RESULT_SOURCE if item.get("source_basis") == MATCH_RESULT_SOURCE else SESSION_RESULT_SOURCE ), "match_url": resolve_rcon_scoreboard_match_url( **scoreboard_correlation, ), "capabilities": describe_rcon_historical_read_model()["capabilities"], } def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str, object]: from .rcon_admin_log_storage import get_latest_rcon_player_profile_summaries match = materialized["match"] recent_item = _build_materialized_recent_item(match) profile_summaries = get_latest_rcon_player_profile_summaries( target_key=str(match["target_key"]), player_ids=[str(row["player_id"]) for row in materialized["players"] if row.get("player_id")], ) players = [ _build_player_row( row, profile_summary=profile_summaries.get(str(row.get("player_id"))), ) for row in materialized["players"] ] player_count = len(players) if players else recent_item.get("player_count") return { **recent_item, "match_id": match["match_key"], "game_mode": match.get("game_mode"), "winner": match.get("winner"), "confidence": match.get("confidence_mode"), "source_basis": match.get("source_basis"), "player_count": player_count, "players": players, "timeline": { "event_counts": materialized.get("timeline", []), }, } def _resolve_materialized_player_count(item: dict[str, object]) -> int | None: for key in ( "player_count", "materialized_player_count", "materialized_distinct_player_count", ): value = _coerce_optional_int(item.get(key)) if value is not None and value > 0: return value return None def _build_player_row( row: dict[str, object], *, profile_summary: dict[str, object] | None = None, ) -> dict[str, object]: kills = _coerce_optional_int(row.get("kills")) or 0 deaths = _coerce_optional_int(row.get("deaths")) or 0 player = { "player_name": row.get("player_name"), "team": row.get("team"), "kills": kills, "deaths": deaths, "teamkills": _coerce_optional_int(row.get("teamkills")) or 0, "kd_ratio": round(kills / deaths, 2) if deaths else float(kills), "top_weapons": _top_counter(row.get("weapons_json")), "most_killed": _top_counter(row.get("most_killed_json")), "death_by": _top_counter(row.get("death_by_json")), **build_external_player_profile_fields(player_id=row.get("player_id")), } if profile_summary: player["profile_summary"] = profile_summary return player def _top_counter(raw_value: object, *, limit: int = 5) -> list[dict[str, object]]: if not isinstance(raw_value, str) or not raw_value.strip(): return [] try: payload = json.loads(raw_value) except (NameError, ValueError, TypeError): return [] if not isinstance(payload, dict): return [] rows = [ {"name": str(name), "count": int(count)} for name, count in payload.items() if _coerce_optional_int(count) is not None ] rows.sort(key=lambda item: (-int(item["count"]), str(item["name"]))) return rows[:limit] def _build_materialized_timestamp_payload(item: dict[str, object]) -> dict[str, object]: started_at = item.get("started_at") ended_at = item.get("ended_at") duration_seconds = _calculate_match_duration_seconds(item) has_server_time_duration = bool(duration_seconds and duration_seconds > 0) if started_at and ended_at and started_at == ended_at and has_server_time_duration: return { "started_at": None, "ended_at": None, "closed_at": ended_at, "timestamp_confidence": "server-time-only", } return { "started_at": started_at, "ended_at": ended_at, "closed_at": ended_at or started_at, "timestamp_confidence": "absolute" if started_at or ended_at else "server-time-only", } def _build_materialized_scoreboard_correlation_window( item: dict[str, object], timestamps: dict[str, object], ) -> dict[str, object]: started_at = timestamps.get("started_at") ended_at = timestamps.get("ended_at") if started_at and ended_at: return {"started_at": started_at, "ended_at": ended_at} closed_at = timestamps.get("closed_at") or item.get("ended_at") or item.get("started_at") duration_seconds = _calculate_match_duration_seconds(item) closed_point = _parse_datetime(closed_at) if closed_point is None or not duration_seconds: return {"started_at": started_at, "ended_at": ended_at} started_point = closed_point - timedelta(seconds=int(duration_seconds)) return { "started_at": started_point.isoformat().replace("+00:00", "Z"), "ended_at": closed_point.isoformat().replace("+00:00", "Z"), } def build_materialized_scoreboard_correlation_input( item: dict[str, object], ) -> dict[str, object]: """Build safe candidate correlation inputs for one materialized RCON match.""" timestamps = _build_materialized_timestamp_payload(item) correlation_window = _build_materialized_scoreboard_correlation_window(item, timestamps) return { "server_slug": item.get("external_server_id") or item.get("target_key"), "map_name": item.get("map_pretty_name") or item.get("map_name"), "started_at": correlation_window["started_at"], "ended_at": correlation_window["ended_at"], "duration_seconds": _calculate_match_duration_seconds(item), "allied_score": item.get("allied_score"), "axis_score": item.get("axis_score"), } def _merge_recent_items( primary_items: list[dict[str, object]], fallback_items: list[dict[str, object]], *, limit: int, ) -> list[dict[str, object]]: merged: list[dict[str, object]] = [] seen: set[tuple[object, object]] = set() for item in primary_items + fallback_items: map_payload = item.get("map") if isinstance(item.get("map"), dict) else {} key = ( item.get("server", {}).get("slug") if isinstance(item.get("server"), dict) else None, normalize_map_name(map_payload.get("pretty_name") or map_payload.get("name")), ) if key in seen: continue seen.add(key) merged.append(item) merged.sort(key=lambda row: str(row.get("closed_at") or row.get("ended_at") or row.get("started_at") or ""), reverse=True) return merged[:limit] def _server_display_name(server_slug: object) -> str: slug = str(server_slug or "").strip() if slug == "comunidad-hispana-01": return "Comunidad Hispana #01" if slug == "comunidad-hispana-02": return "Comunidad Hispana #02" return slug or "RCON" def _build_rcon_result(latest_payload: object) -> dict[str, object]: payload = latest_payload if isinstance(latest_payload, dict) else {} allied_score = _coerce_optional_int(payload.get("allied_score")) axis_score = _coerce_optional_int(payload.get("axis_score")) winner = payload.get("winner") if not isinstance(winner, str) or not winner: winner = _resolve_result_winner(allied_score, axis_score) return { "allied_score": allied_score, "axis_score": axis_score, "winner": winner, } def _build_rcon_gamestate(latest_payload: object) -> dict[str, object]: payload = latest_payload if isinstance(latest_payload, dict) else {} return { "game_mode": payload.get("game_mode"), "allied_faction": payload.get("allied_faction"), "axis_faction": payload.get("axis_faction"), "allied_players": _coerce_optional_int(payload.get("allied_players")), "axis_players": _coerce_optional_int(payload.get("axis_players")), "remaining_match_time_seconds": _coerce_optional_int( payload.get("remaining_match_time_seconds") ), "match_time_seconds": _coerce_optional_int(payload.get("match_time_seconds")), "queue_count": _coerce_optional_int(payload.get("queue_count")), "max_queue_count": _coerce_optional_int(payload.get("max_queue_count")), "vip_queue_count": _coerce_optional_int(payload.get("vip_queue_count")), "max_vip_queue_count": _coerce_optional_int(payload.get("max_vip_queue_count")), } def _resolve_result_winner(allied_score: int | None, axis_score: int | None) -> str | None: if allied_score is None or axis_score is None: return None if allied_score > axis_score: return "allied" if axis_score > allied_score: return "axis" return "draw" def _coerce_optional_int(value: object) -> int | None: if value is None: return None try: return int(value) except (TypeError, ValueError): return None def _build_server_summary(item: dict[str, object]) -> dict[str, object]: sample_count = int(item.get("sample_count") or 0) first_last_points = list_rcon_historical_recent_activity( server_key=str(item["target_key"]), limit=1, ) last_sample_at = item.get("last_seen_at") latest_activity = first_last_points[0] if first_last_points else None return { "server": { "slug": item["target_key"], "name": item["display_name"], "external_server_id": item["external_server_id"], "region": item["region"], }, "coverage": { "basis": "rcon-competitive-windows", "status": "available" if int(item.get("window_count") or 0) > 0 else "empty", "window_count": int(item.get("window_count") or 0), "sample_count": sample_count, "first_sample_at": item.get("first_seen_at"), "last_sample_at": last_sample_at, "coverage_hours": _calculate_coverage_hours(item.get("first_seen_at"), last_sample_at), }, "freshness": { "last_successful_capture_at": item.get("last_successful_capture_at"), "minutes_since_last_capture": _minutes_since_timestamp(last_sample_at), "last_run_status": item.get("last_run_status"), "last_error": item.get("last_error"), "last_error_at": item.get("last_error_at"), }, "activity": { "latest_players": latest_activity.get("player_count") if latest_activity else None, "latest_peak_players": latest_activity.get("peak_players") if latest_activity else None, "latest_map": latest_activity.get("map", {}).get("pretty_name") if latest_activity else None, "latest_status": "captured" if latest_activity else None, }, "time_range": { "start": item.get("first_seen_at"), "end": last_sample_at, }, "capabilities": describe_rcon_historical_read_model()["capabilities"], } def _build_all_servers_summary(items: list[dict[str, object]]) -> dict[str, object]: total_samples = sum(int(item["coverage"].get("sample_count") or 0) for item in items) last_points = [ item["time_range"].get("end") for item in items if item["time_range"].get("end") ] last_capture_at = max(last_points) if last_points else None return { "server": { "slug": ALL_SERVERS_SLUG, "name": "Todos", "external_server_id": None, "region": None, }, "coverage": { "basis": "rcon-competitive-windows-aggregate", "status": "available" if total_samples > 0 else "empty", "sample_count": total_samples, "first_sample_at": None, "last_sample_at": last_capture_at, "coverage_hours": None, }, "freshness": { "last_successful_capture_at": last_capture_at, "minutes_since_last_capture": _minutes_since_timestamp(last_capture_at), "last_run_status": None, "last_error": None, "last_error_at": None, }, "activity": { "latest_players": None, "latest_max_players": None, "latest_map": None, "latest_status": None, }, "time_range": { "start": None, "end": last_capture_at, }, "server_count": len(items), "capabilities": describe_rcon_historical_read_model()["capabilities"], } def _minutes_since_timestamp(timestamp: str | None) -> int | None: if not timestamp: return None captured_at = _parse_datetime(timestamp) if captured_at is None: return None delta = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc) return max(0, int(delta.total_seconds() // 60)) def _parse_datetime(value: object) -> datetime | None: if isinstance(value, datetime): parsed = value elif isinstance(value, str) and value.strip(): try: parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) except ValueError: return None else: return None if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return parsed.astimezone(timezone.utc) def _calculate_coverage_hours( first_sample_at: object, last_sample_at: object, ) -> float | None: first_point = _parse_datetime(first_sample_at) last_point = _parse_datetime(last_sample_at) if first_point is None or last_point is None: return None delta = last_point - first_point return round(delta.total_seconds() / 3600, 2) def _calculate_duration_seconds(first_seen_at: object, last_seen_at: object) -> int | None: first_point = _parse_datetime(first_seen_at) last_point = _parse_datetime(last_seen_at) if first_point is None or last_point is None: return None return max(0, int((last_point - first_point).total_seconds())) def _calculate_match_duration_seconds(item: dict[str, object]) -> int | None: duration = _calculate_duration_seconds(item.get("started_at"), item.get("ended_at")) if duration: return duration started_server_time = _coerce_optional_int(item.get("started_server_time")) ended_server_time = _coerce_optional_int(item.get("ended_server_time")) if started_server_time is None or ended_server_time is None: return duration return max(0, ended_server_time - started_server_time)