Add personal stats player endpoint
This commit is contained in:
69
ai/tasks/done/TASK-163-add-player-personal-stats-endpoint.md
Normal file
69
ai/tasks/done/TASK-163-add-player-personal-stats-endpoint.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
id: TASK-163
|
||||||
|
title: Add player personal stats endpoint
|
||||||
|
status: done
|
||||||
|
type: backend
|
||||||
|
team: Backend Senior
|
||||||
|
supporting_teams: [Arquitecto de Base de Datos, Arquitecto Python]
|
||||||
|
roadmap_item: foundation
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
# TASK-163 - Add player personal stats endpoint
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implementar únicamente el endpoint backend de estadísticas personales:
|
||||||
|
`GET /api/stats/players/{player_id}`.
|
||||||
|
|
||||||
|
Use existing RCON materialized data for weekly/monthly context.
|
||||||
|
|
||||||
|
## Files modified
|
||||||
|
|
||||||
|
- `backend/app/rcon_historical_player_stats.py` (nuevo)
|
||||||
|
- `backend/app/payloads.py`
|
||||||
|
- `backend/app/routes.py`
|
||||||
|
|
||||||
|
## Endpoint created
|
||||||
|
|
||||||
|
- `GET /api/stats/players/{player_id}`
|
||||||
|
- Parámetros:
|
||||||
|
- `server_id` (opcional, alias `server`)
|
||||||
|
- `timeframe` (`weekly` | `monthly`, validación en ruta)
|
||||||
|
- Respuesta:
|
||||||
|
- `status`
|
||||||
|
- `data.player_id`
|
||||||
|
- `data.player_name`
|
||||||
|
- `data.matches_considered`
|
||||||
|
- `data.kills`
|
||||||
|
- `data.deaths`
|
||||||
|
- `data.teamkills`
|
||||||
|
- `data.kd_ratio`
|
||||||
|
- `data.kills_per_match`
|
||||||
|
- `data.deaths_per_match`
|
||||||
|
- `data.window_start`
|
||||||
|
- `data.window_end`
|
||||||
|
- `data.window_kind`
|
||||||
|
- `data.weekly_ranking`
|
||||||
|
- `data.monthly_ranking`
|
||||||
|
- `data.source`
|
||||||
|
|
||||||
|
## Validaciones realizadas
|
||||||
|
|
||||||
|
- `python -m compileall backend/app`
|
||||||
|
- `scripts/run-integration-tests.ps1`
|
||||||
|
- Endpoint manual con backend levantado y `Invoke-WebRequest`:
|
||||||
|
- Query corta: `/api/stats/players/a?timeframe=weekly` => `200 OK`
|
||||||
|
- Query normal: `/api/stats/players/valid-player-uuid?timeframe=monthly` => `200 OK`
|
||||||
|
- Sin resultados: `/api/stats/players/nonexistent-9999?timeframe=weekly` => `200 OK` con métricas en cero
|
||||||
|
- Rechazo de timeframe inválido: `/api/stats/players/a?timeframe=yearly` => `400 Bad Request`
|
||||||
|
|
||||||
|
## Limitaciones conocidas
|
||||||
|
|
||||||
|
- El ranking semanal/mensual incluye solo jugadores con `kills > 0`.
|
||||||
|
- `source_range` puede ir en `null` cuando no hay datos materializados.
|
||||||
|
- `player_name` se resuelve al `player_id` si no hay nombre persistido en el rango solicitado.
|
||||||
|
|
||||||
|
## Siguiente task recomendada
|
||||||
|
|
||||||
|
- Endpoint de vista inicial de estadísticas personales del jugador en frontend (consumo del contrato `GET /api/stats/players/{player_id}`).
|
||||||
@@ -49,6 +49,8 @@ from .historical_storage import (
|
|||||||
list_weekly_top_kills,
|
list_weekly_top_kills,
|
||||||
)
|
)
|
||||||
from .rcon_historical_read_model import get_rcon_historical_match_detail
|
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 .normalizers import normalize_map_name
|
||||||
from .rcon_client import load_rcon_targets, query_live_server_sample
|
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
|
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")
|
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:
|
def _source_when_present(*values: object, source: str) -> str | None:
|
||||||
return source if any(value is not None for value in values) else 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(
|
def build_weekly_leaderboard_payload(
|
||||||
*,
|
*,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
|
|||||||
513
backend/app/rcon_historical_player_stats.py
Normal file
513
backend/app/rcon_historical_player_stats.py
Normal 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))
|
||||||
@@ -7,6 +7,7 @@ from urllib.parse import parse_qs, urlparse
|
|||||||
|
|
||||||
from .config import get_historical_data_source_kind
|
from .config import get_historical_data_source_kind
|
||||||
from .payloads import (
|
from .payloads import (
|
||||||
|
build_stats_player_profile_payload,
|
||||||
build_community_payload,
|
build_community_payload,
|
||||||
build_current_match_kill_feed_payload,
|
build_current_match_kill_feed_payload,
|
||||||
build_current_match_player_stats_payload,
|
build_current_match_player_stats_payload,
|
||||||
@@ -40,6 +41,7 @@ from .payloads import (
|
|||||||
build_weekly_leaderboard_snapshot_payload,
|
build_weekly_leaderboard_snapshot_payload,
|
||||||
build_weekly_leaderboard_payload,
|
build_weekly_leaderboard_payload,
|
||||||
build_weekly_top_kills_payload,
|
build_weekly_top_kills_payload,
|
||||||
|
build_stats_player_search_payload,
|
||||||
)
|
)
|
||||||
from .rcon_historical_leaderboards import build_rcon_materialized_leaderboard_snapshot_payload
|
from .rcon_historical_leaderboards import build_rcon_materialized_leaderboard_snapshot_payload
|
||||||
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
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.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||||
return HTTPStatus.OK, build_server_history_payload(limit=limit)
|
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":
|
if parsed.path == "/api/current-match":
|
||||||
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||||
if not server_slug:
|
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.NOT_FOUND, build_error_payload("Current match server is not supported")
|
||||||
return HTTPStatus.OK, build_current_match_payload(server_slug=server_slug)
|
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":
|
if parsed.path == "/api/current-match/kills":
|
||||||
limit = _parse_limit(parsed.query)
|
limit = _parse_limit(parsed.query)
|
||||||
if limit is None:
|
if limit is None:
|
||||||
@@ -393,3 +429,10 @@ def _parse_limit(query: str) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return limit
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user