344 lines
13 KiB
Python
344 lines
13 KiB
Python
"""Read-only minimal HTTP model over prospective RCON historical persistence."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from .historical_storage import ALL_SERVERS_SLUG
|
|
from .normalizers import normalize_map_name
|
|
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,
|
|
)
|
|
|
|
|
|
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."""
|
|
normalized_server_key = None if server_key == ALL_SERVERS_SLUG else server_key
|
|
items = list_rcon_historical_competitive_windows(target_key=normalized_server_key, limit=limit)
|
|
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"],
|
|
"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",
|
|
"capabilities": item.get("capabilities"),
|
|
"minutes_since_capture": _minutes_since_timestamp(item.get("last_seen_at")),
|
|
}
|
|
for item in items
|
|
]
|
|
|
|
|
|
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": "approximate",
|
|
"competitive_quality": "partial",
|
|
"result": "session-score",
|
|
"gamestate": "session",
|
|
"player_stats": "unavailable",
|
|
},
|
|
"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."""
|
|
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",
|
|
"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_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 = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
if captured_at.tzinfo is None:
|
|
captured_at = captured_at.replace(tzinfo=timezone.utc)
|
|
delta = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc)
|
|
return max(0, int(delta.total_seconds() // 60))
|
|
|
|
|
|
def _calculate_coverage_hours(
|
|
first_sample_at: str | None,
|
|
last_sample_at: str | None,
|
|
) -> float | None:
|
|
if not first_sample_at or not last_sample_at:
|
|
return None
|
|
first_point = datetime.fromisoformat(first_sample_at.replace("Z", "+00:00"))
|
|
last_point = datetime.fromisoformat(last_sample_at.replace("Z", "+00:00"))
|
|
if first_point.tzinfo is None:
|
|
first_point = first_point.replace(tzinfo=timezone.utc)
|
|
if last_point.tzinfo is None:
|
|
last_point = last_point.replace(tzinfo=timezone.utc)
|
|
delta = last_point.astimezone(timezone.utc) - first_point.astimezone(timezone.utc)
|
|
return round(delta.total_seconds() / 3600, 2)
|