From 6292c8cf34a64d692d7b8e5cb1fca8cf8f64308a Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Mon, 8 Jun 2026 12:11:54 +0200 Subject: [PATCH] Add personal stats player endpoint --- ...-163-add-player-personal-stats-endpoint.md | 69 +++ backend/app/payloads.py | 88 +++ backend/app/rcon_historical_player_stats.py | 513 ++++++++++++++++++ backend/app/routes.py | 43 ++ 4 files changed, 713 insertions(+) create mode 100644 ai/tasks/done/TASK-163-add-player-personal-stats-endpoint.md create mode 100644 backend/app/rcon_historical_player_stats.py diff --git a/ai/tasks/done/TASK-163-add-player-personal-stats-endpoint.md b/ai/tasks/done/TASK-163-add-player-personal-stats-endpoint.md new file mode 100644 index 0000000..3e98c32 --- /dev/null +++ b/ai/tasks/done/TASK-163-add-player-personal-stats-endpoint.md @@ -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}`). diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 69efea5..fe8140b 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -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, diff --git a/backend/app/rcon_historical_player_stats.py b/backend/app/rcon_historical_player_stats.py new file mode 100644 index 0000000..cadd739 --- /dev/null +++ b/backend/app/rcon_historical_player_stats.py @@ -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)) diff --git a/backend/app/routes.py b/backend/app/routes.py index c906413..904dc34 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -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)