Add personal stats player endpoint

This commit is contained in:
devRaGonSa
2026-06-08 12:11:54 +02:00
parent 1b3cbbfb1b
commit 6292c8cf34
4 changed files with 713 additions and 0 deletions

View File

@@ -49,6 +49,8 @@ from .historical_storage import (
list_weekly_top_kills,
)
from .rcon_historical_read_model import get_rcon_historical_match_detail
from .rcon_historical_player_stats import search_rcon_materialized_players
from .rcon_historical_player_stats import get_rcon_materialized_player_stats
from .normalizers import normalize_map_name
from .rcon_client import load_rcon_targets, query_live_server_sample
from .rcon_admin_log_storage import list_current_match_kill_feed, list_current_match_player_stats
@@ -460,6 +462,23 @@ def _utc_timestamp_now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _to_iso_or_none(value: object) -> str | None:
if value is None:
return 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).isoformat().replace("+00:00", "Z")
def _source_when_present(*values: object, source: str) -> str | None:
return source if any(value is not None for value in values) else None
@@ -573,6 +592,75 @@ def build_historical_leaderboard_payload(
}
def build_stats_player_search_payload(
*,
query: str,
server_id: str | None = None,
limit: int = 10,
) -> dict[str, object]:
"""Return lightweight player search results for future stats UX flows."""
normalized_query = query.strip()
if not normalized_query:
raise ValueError("Query cannot be empty.")
result = search_rcon_materialized_players(
query=normalized_query,
server_id=server_id,
limit=limit,
)
return {
"status": "ok",
"data": {
"query": result["query"],
"server_id": result["server_id"],
"items": result["items"],
},
}
def build_stats_player_profile_payload(
*,
player_id: str,
server_id: str | None = None,
timeframe: str = "weekly",
) -> dict[str, object]:
"""Return personal RCON materialized stats and weekly/monthly ranking context."""
result = get_rcon_materialized_player_stats(
player_id=player_id,
server_id=server_id,
timeframe=timeframe,
)
kills = int(result.get("kills", 0) or 0)
deaths = int(result.get("deaths", 0) or 0)
matches_considered = int(result.get("matches_considered", 0) or 0)
teamkills = int(result.get("teamkills", 0) or 0)
kills_per_match = round(kills / matches_considered, 2) if matches_considered else 0.0
deaths_per_match = round(deaths / matches_considered, 2) if matches_considered else 0.0
kd_ratio = round(kills / deaths, 2) if deaths else float(kills)
return {
"status": "ok",
"data": {
"player_id": result.get("player_id"),
"player_name": result.get("player_name"),
"server_id": result.get("server_id"),
"timeframe": result.get("timeframe"),
"window_start": _to_iso_or_none(result.get("window_start")),
"window_end": _to_iso_or_none(result.get("window_end")),
"window_kind": result.get("window_kind"),
"matches_considered": matches_considered,
"kills": kills,
"deaths": deaths,
"teamkills": teamkills,
"kd_ratio": kd_ratio,
"kills_per_match": kills_per_match,
"deaths_per_match": deaths_per_match,
"weekly_ranking": result.get("weekly_ranking"),
"monthly_ranking": result.get("monthly_ranking"),
"source": result.get("source"),
},
}
def build_weekly_leaderboard_payload(
*,
limit: int = 10,

View File

@@ -0,0 +1,513 @@
"""Search helpers for RCON materialized player statistics."""
from __future__ import annotations
from contextlib import closing
from datetime import datetime, timezone
from pathlib import Path
from .config import use_postgres_rcon_storage
from .historical_storage import ALL_SERVERS_SLUG
from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage
from .rcon_historical_leaderboards import select_leaderboard_window
from .sqlite_utils import connect_sqlite_readonly
from .rcon_historical_leaderboards import _to_iso
def search_rcon_materialized_players(
*,
query: str,
server_id: str | None = None,
limit: int = 10,
db_path: Path | None = None,
) -> dict[str, object]:
"""Search players in materialized RCON/AdminLog statistics."""
normalized_query = query.strip()
if not normalized_query:
raise ValueError("Query cannot be empty.")
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
resolved_server_id = _normalize_server_id(server_id)
normalized_pattern = _escape_like_pattern(normalized_query)
like_pattern = f"%{normalized_pattern.lower()}%"
connection_scope = _connect_scope(resolved_path, db_path=db_path)
with connection_scope as connection:
scope_sql, scope_params = _build_scope_sql(resolved_server_id)
rows = connection.execute(
f"""
SELECT
stats.player_id,
COUNT(DISTINCT stats.match_key) AS matches_considered,
MAX(COALESCE(matches.ended_at, matches.started_at)) AS last_seen_at
FROM rcon_match_player_stats AS stats
INNER JOIN rcon_materialized_matches AS matches
ON matches.target_key = stats.target_key
AND matches.match_key = stats.match_key
WHERE matches.source_basis = ?
AND (
LOWER(COALESCE(stats.player_name, '')) LIKE LOWER(?) ESCAPE '\\'
OR LOWER(stats.player_id) LIKE LOWER(?)
)
{scope_sql}
GROUP BY stats.player_id
ORDER BY matches_considered DESC, MAX(COALESCE(matches.ended_at, matches.started_at)) DESC
LIMIT ?
""",
[MATCH_RESULT_SOURCE, like_pattern, like_pattern, *scope_params, limit],
).fetchall()
player_rows = [_normalize_player_row(row) for row in rows]
player_ids = [row["player_id"] for row in player_rows if isinstance(row["player_id"], str)]
if not player_ids:
return {
"server_id": resolved_server_id,
"query": normalized_query,
"items": [],
}
latest_names = _lookup_latest_player_names(
db_path=resolved_path,
player_ids=player_ids,
server_id=resolved_server_id,
pattern_like=like_pattern,
)
servers_seen = _lookup_servers_seen(
db_path=resolved_path,
player_ids=player_ids,
server_id=resolved_server_id,
)
items: list[dict[str, object]] = []
for row in player_rows:
player_id = str(row["player_id"] or "").strip()
if not player_id:
continue
item: dict[str, object] = {
"player_id": player_id,
"player_name": latest_names.get(player_id) or row["player_name"],
"matches_considered": int(row["matches_considered"] or 0),
"last_seen_at": row["last_seen_at"],
}
if player_ids:
item["servers_seen"] = servers_seen.get(player_id, [])
items.append(item)
return {
"server_id": resolved_server_id,
"query": normalized_query,
"items": items,
}
def get_rcon_materialized_player_stats(
*,
player_id: str,
server_id: str | None = None,
timeframe: str = "weekly",
db_path: Path | None = None,
) -> dict[str, object]:
"""Return personal stats and weekly/monthly ranking context for one player."""
normalized_player_id = player_id.strip()
if not normalized_player_id:
raise ValueError("player_id is required")
resolved_timeframe = timeframe.strip().lower() if isinstance(timeframe, str) else "weekly"
if resolved_timeframe not in {"weekly", "monthly", "all"}:
raise ValueError("Invalid timeframe parameter")
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
resolved_server_id = _normalize_server_id(server_id)
now = datetime.now(timezone.utc)
with _connect_scope(resolved_path, db_path=db_path) as connection:
selected_window = _build_player_timeframe_window(
connection=connection,
server_id=resolved_server_id,
timeframe=resolved_timeframe,
now=now,
)
player_stats = _fetch_player_stats(
connection=connection,
player_id=normalized_player_id,
server_id=resolved_server_id,
window=selected_window if resolved_timeframe != "all" else None,
)
source_range = _fetch_source_range(
connection=connection,
server_id=resolved_server_id,
window=selected_window if resolved_timeframe != "all" else None,
)
weekly_window = select_leaderboard_window(
connection=connection,
server_key=resolved_server_id,
timeframe="weekly",
now=now,
)
monthly_window = select_leaderboard_window(
connection=connection,
server_key=resolved_server_id,
timeframe="monthly",
now=now,
)
weekly_ranking = _fetch_player_ranking(
connection=connection,
player_id=normalized_player_id,
server_id=resolved_server_id,
window=weekly_window,
)
monthly_ranking = _fetch_player_ranking(
connection=connection,
player_id=normalized_player_id,
server_id=resolved_server_id,
window=monthly_window,
)
return {
"player_id": normalized_player_id,
"server_id": resolved_server_id,
"timeframe": resolved_timeframe,
"player_name": player_stats.get("player_name"),
"window_start": selected_window["start"] if selected_window else None,
"window_end": selected_window["end"] if selected_window else None,
"window_kind": selected_window["kind"] if selected_window else "all-time",
"matches_considered": player_stats.get("matches_considered", 0),
"kills": player_stats.get("kills", 0),
"deaths": player_stats.get("deaths", 0),
"teamkills": player_stats.get("teamkills", 0),
"weekly_ranking": weekly_ranking,
"monthly_ranking": monthly_ranking,
"source": {
"primary_source": "rcon",
"read_model": "rcon-materialized-admin-log-player-stats",
"generated_at": _to_iso(now),
"source_range_start": _to_iso(source_range[0]) if source_range[0] else None,
"source_range_end": _to_iso(source_range[1]) if source_range[1] else None,
"freshness": "runtime",
},
}
def _build_player_timeframe_window(
*,
connection: object,
server_id: str | None,
timeframe: str,
now: datetime,
) -> dict[str, object] | None:
if timeframe == "all":
return None
window = select_leaderboard_window(
connection=connection,
server_key=server_id,
timeframe=timeframe,
now=now,
)
return {
"start": window["start"],
"end": window["end"],
"kind": window["kind"],
}
def _fetch_player_stats(
*,
connection: object,
player_id: str,
server_id: str | None,
window: dict[str, object] | None,
) -> dict[str, object]:
scope_sql, scope_params = _build_scope_sql(server_id)
base_where = [
"matches.source_basis = ?",
"stats.player_id = ?",
]
params: list[object] = [MATCH_RESULT_SOURCE, player_id]
if window is not None:
base_where.append(
"COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?"
)
base_where.append(
"COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) <= ?"
)
params.extend([_to_iso(window["start"]), _to_iso(window["end"])])
# Scope filter is applied directly in SQL via ``scope_sql`` placeholders.
where_sql = " AND ".join(base_where)
row = connection.execute(
f"""
SELECT
COALESCE(MAX(stats.player_name), stats.player_id) AS player_name,
COUNT(DISTINCT stats.match_key) AS matches_considered,
SUM(COALESCE(stats.kills, 0)) AS kills,
SUM(COALESCE(stats.deaths, 0)) AS deaths,
SUM(COALESCE(stats.teamkills, 0)) AS teamkills
FROM rcon_match_player_stats AS stats
INNER JOIN rcon_materialized_matches AS matches
ON matches.target_key = stats.target_key
AND matches.match_key = stats.match_key
WHERE {where_sql} {scope_sql}
""",
[*params, *scope_params],
).fetchone()
if not row:
return {
"player_name": None,
"matches_considered": 0,
"kills": 0,
"deaths": 0,
"teamkills": 0,
}
return {
"player_name": str(row["player_name"] or player_id),
"matches_considered": int(row["matches_considered"] or 0),
"kills": int(row["kills"] or 0),
"deaths": int(row["deaths"] or 0),
"teamkills": int(row["teamkills"] or 0),
}
def _fetch_player_ranking(
*,
connection: object,
player_id: str,
server_id: str | None,
window: dict[str, object],
) -> dict[str, object]:
scope_sql, scope_params = _build_scope_sql(server_id)
row = connection.execute(
f"""
SELECT ranking_position
FROM (
SELECT
ranked.player_id,
ROW_NUMBER() OVER (
ORDER BY ranked.kills DESC, ranked.matches_considered DESC, ranked.player_name ASC
) AS ranking_position
FROM (
SELECT
stats.player_id,
COALESCE(MAX(stats.player_name), '') AS player_name,
SUM(COALESCE(stats.kills, 0)) AS kills,
COUNT(DISTINCT stats.match_key) AS matches_considered
FROM rcon_match_player_stats AS stats
INNER JOIN rcon_materialized_matches AS matches
ON matches.target_key = stats.target_key
AND matches.match_key = stats.match_key
WHERE matches.source_basis = ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) <= ?
{scope_sql}
GROUP BY stats.player_id
) AS ranked
WHERE ranked.kills > 0
) AS ranking
WHERE ranking.player_id = ?
""",
[
MATCH_RESULT_SOURCE,
_to_iso(window["start"]),
_to_iso(window["end"]),
*scope_params,
player_id,
],
).fetchone()
ranking_position: int | None = None
if row and row["ranking_position"] is not None:
ranking_position = int(row["ranking_position"])
return {
"metric": "kills",
"ranking_position": ranking_position,
"window_kind": window["kind"],
"window_start": _to_iso(window["start"]),
"window_end": _to_iso(window["end"]),
}
def _fetch_source_range(
*,
connection: object,
server_id: str | None,
window: dict[str, object] | None,
) -> tuple[datetime | None, datetime | None]:
scope_sql, scope_params = _build_scope_sql(server_id, table_alias="matches")
if window is None:
row = connection.execute(
f"""
SELECT
MIN(COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))) AS source_range_start,
MAX(COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))) AS source_range_end
FROM rcon_materialized_matches AS matches
WHERE matches.source_basis = ?
{scope_sql}
""",
[MATCH_RESULT_SOURCE, *scope_params],
).fetchone()
else:
row = connection.execute(
f"""
SELECT
MIN(COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))) AS source_range_start,
MAX(COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))) AS source_range_end
FROM rcon_materialized_matches AS matches
WHERE matches.source_basis = ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) <= ?
{scope_sql}
""",
[MATCH_RESULT_SOURCE, _to_iso(window["start"]), _to_iso(window["end"]), *scope_params],
).fetchone()
if not row:
return None, None
return _to_datetime_or_none(row["source_range_start"]), _to_datetime_or_none(row["source_range_end"])
def _to_datetime_or_none(value: object) -> datetime | None:
if value is None:
return 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 _lookup_latest_player_names(
*,
db_path: Path,
player_ids: list[str],
server_id: str | None,
pattern_like: str,
) -> dict[str, str]:
placeholders = ", ".join(["?"] * len(player_ids))
scope_sql, scope_params = _build_scope_sql(server_id)
with _connect_scope(db_path, db_path=db_path) as connection:
rows = connection.execute(
f"""
SELECT
stats.player_id,
stats.player_name,
COALESCE(matches.ended_at, matches.started_at) AS last_seen_at
FROM rcon_match_player_stats AS stats
INNER JOIN rcon_materialized_matches AS matches
ON matches.target_key = stats.target_key
AND matches.match_key = stats.match_key
WHERE stats.player_id IN ({placeholders})
AND matches.source_basis = ?
AND (
LOWER(COALESCE(stats.player_name, '')) LIKE LOWER(?) ESCAPE '\\'
OR LOWER(stats.player_id) LIKE LOWER(?)
)
{scope_sql}
ORDER BY COALESCE(matches.ended_at, matches.started_at) DESC
""",
[*player_ids, MATCH_RESULT_SOURCE, pattern_like, pattern_like, *scope_params],
).fetchall()
latest_names: dict[str, str] = {}
for row in rows:
player_id = str(row["player_id"] or "").strip()
if player_id and player_id not in latest_names:
latest_names[player_id] = str(row["player_name"] or player_id)
return latest_names
def _lookup_servers_seen(
*,
db_path: Path,
player_ids: list[str],
server_id: str | None,
) -> dict[str, list[str]]:
placeholders = ", ".join(["?"] * len(player_ids))
scope_sql, scope_params = _build_scope_sql(server_id)
with _connect_scope(db_path, db_path=db_path) as connection:
rows = connection.execute(
f"""
SELECT
stats.player_id,
COALESCE(matches.external_server_id, matches.target_key) AS server_id
FROM rcon_match_player_stats AS stats
INNER JOIN rcon_materialized_matches AS matches
ON matches.target_key = stats.target_key
AND matches.match_key = stats.match_key
WHERE stats.player_id IN ({placeholders})
AND matches.source_basis = ?
{scope_sql}
GROUP BY stats.player_id, COALESCE(matches.external_server_id, matches.target_key)
ORDER BY stats.player_id, server_id ASC
""",
[*player_ids, MATCH_RESULT_SOURCE, *scope_params],
).fetchall()
by_player: dict[str, list[str]] = {}
for row in rows:
player_id = str(row["player_id"] or "").strip()
if not player_id:
continue
by_player.setdefault(player_id, [])
server_name = str(row["server_id"] or "").strip()
if server_name and server_name not in by_player[player_id]:
by_player[player_id].append(server_name)
return by_player
def _build_scope_sql(
server_id: str | None,
*,
table_alias: str = "matches",
) -> tuple[str, list[object]]:
if not server_id or server_id == ALL_SERVERS_SLUG:
return "", []
return (
f"AND ({table_alias}.target_key = ? OR {table_alias}.external_server_id = ?)",
[server_id, server_id],
)
def _normalize_server_id(server_id: str | None) -> str:
normalized = str(server_id or "").strip()
if not normalized or normalized.lower() == "all":
return ALL_SERVERS_SLUG
return normalized
def _escape_like_pattern(raw_value: str) -> str:
return (
raw_value.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
.strip()
)
def _normalize_player_row(row: object) -> dict[str, object]:
if isinstance(row, dict):
return dict(row)
return {
"player_id": row[0] if row else None,
"matches_considered": row[1] if row else 0,
"last_seen_at": row[2] if row else None,
"player_name": None,
}
def _connect_scope(resolved_path: Path, *, db_path: Path | None = None):
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
return connect_postgres_compat()
return closing(connect_sqlite_readonly(resolved_path))

View File

@@ -7,6 +7,7 @@ from urllib.parse import parse_qs, urlparse
from .config import get_historical_data_source_kind
from .payloads import (
build_stats_player_profile_payload,
build_community_payload,
build_current_match_kill_feed_payload,
build_current_match_player_stats_payload,
@@ -40,6 +41,7 @@ from .payloads import (
build_weekly_leaderboard_snapshot_payload,
build_weekly_leaderboard_payload,
build_weekly_top_kills_payload,
build_stats_player_search_payload,
)
from .rcon_historical_leaderboards import build_rcon_materialized_leaderboard_snapshot_payload
from .scoreboard_origins import get_trusted_public_scoreboard_origin
@@ -66,6 +68,23 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
return HTTPStatus.OK, build_server_history_payload(limit=limit)
if parsed.path == "/api/stats/players/search":
params = parse_qs(parsed.query)
query = str(params.get("q", [None])[0] or "").strip()
if not query:
return HTTPStatus.BAD_REQUEST, build_error_payload("Query parameter is required")
limit = _parse_limit_with_default(parsed.query, default=10)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_id = params.get("server_id", [None])[0]
if server_id is None:
server_id = params.get("server", [None])[0]
return HTTPStatus.OK, build_stats_player_search_payload(
query=query,
server_id=server_id,
limit=limit,
)
if parsed.path == "/api/current-match":
server_slug = parse_qs(parsed.query).get("server", [None])[0]
if not server_slug:
@@ -74,6 +93,23 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported")
return HTTPStatus.OK, build_current_match_payload(server_slug=server_slug)
if parsed.path.startswith("/api/stats/players/"):
player_id = parsed.path.removeprefix("/api/stats/players/").strip()
if not player_id:
return HTTPStatus.BAD_REQUEST, build_error_payload("Player id is required")
params = parse_qs(parsed.query)
timeframe = params.get("timeframe", ["weekly"])[0] or "weekly"
if timeframe not in {"weekly", "monthly"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
server_id = params.get("server_id", [None])[0]
if server_id is None:
server_id = params.get("server", [None])[0]
return HTTPStatus.OK, build_stats_player_profile_payload(
player_id=player_id,
server_id=server_id,
timeframe=timeframe,
)
if parsed.path == "/api/current-match/kills":
limit = _parse_limit(parsed.query)
if limit is None:
@@ -393,3 +429,10 @@ def _parse_limit(query: str) -> int | None:
return None
return limit
def _parse_limit_with_default(query: str, default: int = 20) -> int | None:
params = parse_qs(query)
if "limit" not in params:
return default
return _parse_limit(query)