diff --git a/ai/tasks/pending/TASK-184-define-ranking-metric-expansion-contract.md b/ai/tasks/done/TASK-184-define-ranking-metric-expansion-contract.md similarity index 60% rename from ai/tasks/pending/TASK-184-define-ranking-metric-expansion-contract.md rename to ai/tasks/done/TASK-184-define-ranking-metric-expansion-contract.md index 6304a76..a384e56 100644 --- a/ai/tasks/pending/TASK-184-define-ranking-metric-expansion-contract.md +++ b/ai/tasks/done/TASK-184-define-ranking-metric-expansion-contract.md @@ -1,7 +1,7 @@ --- id: TASK-184-define-ranking-metric-expansion-contract title: Define ranking metric expansion contract -status: pending +status: done type: documentation team: Analista supporting_teams: @@ -102,17 +102,73 @@ Before completing the task ensure: ## Outcome -Document: +Supported Ranking V1.1 metrics documented in `docs/global-ranking-page-plan.md`: -- supported Ranking V1.1 metrics -- formula for each metric -- ordering and tie-break expectations -- supported timeframes by metric -- annual snapshot limitations and safety rules -- expected payload adjustments, if any -- controlled error behavior for unsupported metrics -- validation performed -- explicit note that no automated tests apply if this remains documentation-only +- `kills` +- `deaths` +- `teamkills` +- `matches_considered` +- `kd_ratio` +- `kills_per_match` + +Formula and aggregation rules documented: + +- `kills = SUM(kills)` +- `deaths = SUM(deaths)` +- `teamkills = SUM(teamkills)` +- `matches_considered = COUNT(DISTINCT match_key)` +- `kd_ratio = SUM(kills) / SUM(deaths)` +- `kills_per_match = SUM(kills) / COUNT(DISTINCT match_key)` + +Safety rules documented: + +- `deaths=0` returns finite display-safe `kd_ratio` using kills as the fallback value +- `matches_considered=0` returns `kills_per_match = 0` + +Ordering and tie-break expectations documented: + +- totals metrics sort by active metric desc, then stable tie-break fields +- `matches_considered` ties break on `kills` desc, then `player_name` asc +- ratio metrics tie-break on `kills` desc, `matches_considered` desc, `player_name` asc + +Supported timeframes by metric documented: + +- weekly: all V1.1 metrics through the materialized RCON runtime leaderboard +- monthly: all V1.1 metrics through the materialized RCON runtime leaderboard +- annual: snapshot-safe path only; `kills` required, extra metrics only if an explicit snapshot-backed read path exists + +Annual snapshot limitations and safety rules documented: + +- no runtime annual recomputation on public requests +- unsupported annual metrics must return controlled `400` +- until extra annual snapshots exist, annual remains effectively `kills`-only + +Expected payload adjustments documented: + +- `metric_value` remains the active display/sort field +- weekly/monthly payloads may include rounded ratio fields and `kills_per_match` +- annual payload shape remains stable and only expands when snapshot-backed safely + +Controlled error behavior documented: + +- unsupported metric, unsupported timeframe and unsupported annual metric must fail with controlled request-validation errors +- backend must not silently downgrade unsupported requests to `kills` + +Validation performed: + +- reviewed `docs/global-ranking-page-plan.md` +- reviewed `docs/stats-section-functional-plan.md` +- reviewed `docs/annual-ranking-snapshot-runbook.md` +- reviewed `backend/app/rcon_historical_leaderboards.py` +- reviewed `backend/app/rcon_annual_rankings.py` +- reviewed `backend/app/routes.py` +- reviewed `backend/app/payloads.py` +- reviewed `ai/tasks/done/TASK-183-review-global-ranking-implementation.md` +- confirmed `git diff --name-only` scope stays documentation-only for this task + +Automated tests: + +- No automated tests apply because this task remained documentation-only. ## Change Budget diff --git a/ai/tasks/pending/TASK-185-add-ranking-extra-metrics-backend-support.md b/ai/tasks/done/TASK-185-add-ranking-extra-metrics-backend-support.md similarity index 55% rename from ai/tasks/pending/TASK-185-add-ranking-extra-metrics-backend-support.md rename to ai/tasks/done/TASK-185-add-ranking-extra-metrics-backend-support.md index ea5c0ab..4478c70 100644 --- a/ai/tasks/pending/TASK-185-add-ranking-extra-metrics-backend-support.md +++ b/ai/tasks/done/TASK-185-add-ranking-extra-metrics-backend-support.md @@ -1,7 +1,7 @@ --- id: TASK-185-add-ranking-extra-metrics-backend-support title: Add ranking extra metrics backend support -status: pending +status: done type: backend team: Backend Senior supporting_teams: @@ -105,14 +105,72 @@ Before completing the task ensure: ## Outcome -Document: +Metrics added for `GET /api/ranking` weekly/monthly reads: -- metrics added -- formulas applied in backend ranking logic -- annual metric behavior and limitations -- validations executed -- known limitations -- recommended next task: expose the new metrics safely in Ranking frontend UX +- `kills` +- `deaths` +- `teamkills` +- `matches_considered` +- `kd_ratio` +- `kills_per_match` + +Backend formulas and ordering applied: + +- `kills = SUM(kills)` ordered by `kills` desc, `matches_considered` desc, `player_name` asc +- `deaths = SUM(deaths)` ordered by `deaths` desc, `matches_considered` desc, `player_name` asc +- `teamkills = SUM(teamkills)` ordered by `teamkills` desc, `matches_considered` desc, `player_name` asc +- `matches_considered = COUNT(DISTINCT match_key)` ordered by `matches_considered` desc, `kills` desc, `player_name` asc +- `kd_ratio = SUM(kills) / SUM(deaths)` with `deaths=0 -> kills`, ordered by `kd_ratio` desc, `kills` desc, `matches_considered` desc, `player_name` asc +- `kills_per_match = SUM(kills) / COUNT(DISTINCT match_key)` with `matches_considered=0 -> 0`, ordered by `kills_per_match` desc, `kills` desc, `matches_considered` desc, `player_name` asc + +Implementation summary: + +- `backend/app/rcon_historical_leaderboards.py` now supports the V1.1 ranking metrics while preserving the existing materialized RCON read model. +- `backend/app/routes.py` now validates the expanded public Ranking metric set without changing the endpoint shape. +- `backend/app/payloads.py` now preserves decimal `metric_value` when needed and exposes `kills_per_match` in normalized ranking items. +- `backend/app/rcon_annual_rankings.py` remains snapshot-safe and returns a controlled annual-specific `400` for unsupported annual metrics. +- `scripts/run-stats-validation.ps1` now covers the new happy paths and annual guardrail failures. + +Annual metric behavior and limitations: + +- annual remains snapshot-backed only +- `metric=kills` remains supported +- extra annual metrics currently return controlled `400` +- no runtime full-year recomputation was introduced + +Validations executed: + +- `python -m compileall backend/app/routes.py backend/app/payloads.py backend/app/rcon_historical_leaderboards.py backend/app/rcon_annual_rankings.py` +- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` + +Validation notes: + +- live backend HTTP validation at `http://127.0.0.1:8000` was not available during execution +- route-contract validation still passed through local Python imports + +Scope notes: + +- task-owned modifications stayed within: + - `backend/app/rcon_historical_leaderboards.py` + - `backend/app/routes.py` + - `backend/app/payloads.py` + - `backend/app/rcon_annual_rankings.py` + - `scripts/run-stats-validation.ps1` +- `git diff --name-only` also showed pre-existing or previous-task changes outside this task: + - moved task files from `TASK-184` / `TASK-185` + - `docs/global-ranking-page-plan.md` from `TASK-184` + - unrelated existing workspace change `frontend/assets/img/weapons/black/gewehr_black.svg` +- those files were not modified as part of this backend task. + +Known limitations: + +- annual extra metrics are intentionally blocked until an explicit snapshot-backed implementation exists +- ranking item decimals are validated through route-contract checks, not through a running backend HTTP instance in this run + +Recommended next task: + +- expose the new metrics safely in the Ranking frontend UX. ## Change Budget diff --git a/ai/tasks/pending/TASK-186-polish-ranking-metric-ux-and-limits.md b/ai/tasks/done/TASK-186-polish-ranking-metric-ux-and-limits.md similarity index 53% rename from ai/tasks/pending/TASK-186-polish-ranking-metric-ux-and-limits.md rename to ai/tasks/done/TASK-186-polish-ranking-metric-ux-and-limits.md index 8b670ff..c09d5bf 100644 --- a/ai/tasks/pending/TASK-186-polish-ranking-metric-ux-and-limits.md +++ b/ai/tasks/done/TASK-186-polish-ranking-metric-ux-and-limits.md @@ -1,7 +1,7 @@ --- id: TASK-186-polish-ranking-metric-ux-and-limits title: Polish ranking metric UX and limits -status: pending +status: done type: frontend team: Frontend Senior supporting_teams: @@ -101,14 +101,88 @@ Before completing the task ensure: ## Outcome -Document: +Metrics exposed in Ranking UI: -- metrics exposed in UI -- annual behavior in the UX -- UI limit choices -- error states covered -- validations executed -- recommended follow-ups, if any +- `kills` +- `deaths` +- `teamkills` +- `matches_considered` +- `kd_ratio` +- `kills_per_match` + +Frontend UX changes delivered: + +- `frontend/ranking.html` now exposes the expanded metric selector and wider limit options. +- the ranking table now highlights the active metric dynamically while still showing the core supporting stats (`kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`) +- the metadata strip now shows the active timeframe, active server, active metric, limit, window and source more explicitly +- `frontend/assets/js/ranking.js` now restores filter state from URL parameters and keeps the URL synced as filters change +- `frontend/stats.html` now includes a minimal direct link back to `Ranking` + +Annual behavior in the UX: + +- annual keeps `kills` as the only selectable active metric +- when the user switches to annual, non-snapshot-safe metrics are disabled in the selector +- the page shows an explicit note that annual remains `kills`-only until additional safe snapshots exist +- annual unsupported-metric and missing-snapshot states remain clearly differentiated + +UI limit choices: + +- `Top 5` +- `Top 10` +- `Top 20` +- `Top 50` +- `Top 100` + +Error and state coverage improved: + +- dedicated invalid-limit message for manual URL or parameter manipulation +- dedicated annual kills-only warning +- preserved unsupported metric message +- preserved unsupported timeframe message +- preserved backend offline fallback path +- preserved annual snapshot missing state +- preserved empty-ready states + +Validation updates: + +- `scripts/run-stats-validation.ps1` now asserts the new Ranking metric options, annual kills-only guidance, URL-state handling and the Stats-to-Ranking link + +Validations executed: + +- `node --check frontend/assets/js/ranking.js` +- `node --check frontend/assets/js/stats.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- temporary static serving with `python -m http.server 8081` +- confirmed HTTP `200` for: + - `ranking.html` + - `assets/js/ranking.js` + - `stats.html` + - `index.html` + +Validation notes: + +- local backend HTTP validation at `http://127.0.0.1:8000` was unavailable during the run +- offline fallback behavior was therefore validated through the existing frontend logic plus route-contract checks from the shared validation script + +Scope notes: + +- task-owned frontend changes stayed within: + - `frontend/ranking.html` + - `frontend/assets/js/ranking.js` + - `frontend/assets/css/styles.css` + - `frontend/stats.html` + - `scripts/run-stats-validation.ps1` +- `git diff --name-only` also shows previous-task backend/docs changes and an unrelated existing workspace change: + - moved task files from `TASK-184` / `TASK-185` / `TASK-186` + - `backend/app/*` and `docs/global-ranking-page-plan.md` from prior tasks in this run + - `frontend/assets/img/weapons/black/gewehr_black.svg` +- those files were not modified as part of this frontend task. + +Recommended follow-ups: + +- if a live backend session becomes available, run an explicit browser-side validation pass for every supported metric against real responses +- if annual extra metrics are introduced later, keep the current UX branch but switch from disabled options to active snapshot-backed support only when backend snapshots exist. ## Change Budget diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 728a541..840d2b3 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -500,22 +500,39 @@ def _normalize_global_ranking_items(items: object) -> list[dict[str, object]]: for item in items if isinstance(items, list) else []: if not isinstance(item, dict): continue + matches_considered = int(item.get("matches_considered") or 0) + kills = int(item.get("kills") or 0) normalized_items.append( { "ranking_position": int(item.get("ranking_position") or 0), "player_id": item.get("player_id"), "player_name": item.get("player_name"), - "metric_value": int(item.get("metric_value") or 0), - "matches_considered": int(item.get("matches_considered") or 0), - "kills": int(item.get("kills") or 0), + "metric_value": _coerce_public_metric_value(item.get("metric_value")), + "matches_considered": matches_considered, + "kills": kills, "deaths": int(item.get("deaths") or 0), "teamkills": int(item.get("teamkills") or 0), "kd_ratio": float(item.get("kd_ratio") or 0.0), + "kills_per_match": float( + item.get("kills_per_match") + if item.get("kills_per_match") is not None + else round(kills / matches_considered, 2) if matches_considered else 0.0 + ), } ) return normalized_items +def _coerce_public_metric_value(value: object) -> int | float: + try: + numeric = float(value or 0) + except (TypeError, ValueError): + return 0 + if numeric.is_integer(): + return int(numeric) + return round(numeric, 2) + + def _source_when_present(*values: object, source: str) -> str | None: return source if any(value is not None for value in values) else None @@ -735,12 +752,25 @@ def build_annual_ranking_snapshot_payload( "ranking_position": int(item.get("ranking_position") or 0), "player_id": item.get("player_id"), "player_name": item.get("player_name"), - "metric_value": int(item.get("metric_value") or 0), + "metric_value": _coerce_public_metric_value(item.get("metric_value")), "matches_considered": int(item.get("matches_considered") or 0), "kills": int(item.get("kills") or 0), "deaths": int(item.get("deaths") or 0), "teamkills": int(item.get("teamkills") or 0), "kd_ratio": float(item.get("kd_ratio") or 0.0), + "kills_per_match": ( + float(item.get("kills_per_match")) + if item.get("kills_per_match") is not None + else ( + round( + int(item.get("kills") or 0) + / int(item.get("matches_considered") or 0), + 2, + ) + if int(item.get("matches_considered") or 0) > 0 + else 0.0 + ) + ), } for item in items if isinstance(item, dict) diff --git a/backend/app/rcon_annual_rankings.py b/backend/app/rcon_annual_rankings.py index d7c4813..21d4100 100644 --- a/backend/app/rcon_annual_rankings.py +++ b/backend/app/rcon_annual_rankings.py @@ -205,7 +205,9 @@ def _normalize_server_key(server_key: str | None) -> str: def _normalize_metric(metric: str) -> str: normalized = str(metric or "kills").strip().lower() if normalized != "kills": - raise ValueError("Only metric 'kills' is supported for annual ranking endpoints.") + raise ValueError( + f"Metric '{normalized}' is not supported for annual ranking snapshots." + ) return normalized diff --git a/backend/app/rcon_historical_leaderboards.py b/backend/app/rcon_historical_leaderboards.py index 4da272e..7ce68cf 100644 --- a/backend/app/rcon_historical_leaderboards.py +++ b/backend/app/rcon_historical_leaderboards.py @@ -17,7 +17,16 @@ from .rcon_admin_log_materialization import ( from .sqlite_utils import connect_sqlite_readonly LeaderboardTimeframe = Literal["weekly", "monthly"] -LeaderboardMetric = Literal["kills", "deaths", "matches_over_100_kills", "support"] +LeaderboardMetric = Literal[ + "kills", + "deaths", + "teamkills", + "matches_considered", + "kd_ratio", + "kills_per_match", + "matches_over_100_kills", + "support", +] def build_rcon_materialized_leaderboard_snapshot_payload( @@ -197,12 +206,7 @@ def _fetch_leaderboard_rows( window_end: datetime, ) -> list[dict[str, object]]: scope_sql, scope_params = _build_scope_sql(server_key) - metric_sql = { - "kills": "SUM(COALESCE(stats.kills, 0))", - "deaths": "SUM(COALESCE(stats.deaths, 0))", - "matches_over_100_kills": "SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END)", - }[metric] - having_sql = f"HAVING {metric_sql} > 0" + metric_sql, having_sql, order_by_sql = _resolve_metric_sql(metric) params: list[object] = [ _to_iso(window_start), _to_iso(window_end), @@ -230,7 +234,7 @@ def _fetch_leaderboard_rows( AND TRIM(COALESCE(stats.player_name, '')) != '' GROUP BY stats.player_id, stats.player_name {having_sql} - ORDER BY metric_value DESC, matches_considered DESC, stats.player_name ASC + ORDER BY {order_by_sql} LIMIT ? """, [MATCH_RESULT_SOURCE, *params], @@ -431,6 +435,9 @@ def _count_matches( def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]: kills = _coerce_int(row.get("kills")) deaths = _coerce_int(row.get("deaths")) + matches_considered = _coerce_int(row.get("matches_considered")) + kd_ratio = round(kills / deaths, 2) if deaths else float(kills) + kills_per_match = round(kills / matches_considered, 2) if matches_considered else 0.0 return { "ranking_position": index, "player": { @@ -439,12 +446,13 @@ def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]: }, "player_id": row.get("player_id"), "player_name": row.get("player_name"), - "metric_value": _coerce_int(row.get("metric_value")), - "matches_considered": _coerce_int(row.get("matches_considered")), + "metric_value": _coerce_metric_value(row.get("metric_value")), + "matches_considered": matches_considered, "kills": kills, "deaths": deaths, "teamkills": _coerce_int(row.get("teamkills")), - "kd_ratio": round(kills / deaths, 2) if deaths else float(kills), + "kd_ratio": kd_ratio, + "kills_per_match": kills_per_match, } @@ -554,7 +562,16 @@ def _normalize_timeframe(value: str) -> LeaderboardTimeframe: def _normalize_metric(value: str) -> LeaderboardMetric: normalized = str(value or "kills").strip().lower() - if normalized in {"kills", "deaths", "matches_over_100_kills", "support"}: + if normalized in { + "kills", + "deaths", + "teamkills", + "matches_considered", + "kd_ratio", + "kills_per_match", + "matches_over_100_kills", + "support", + }: return normalized # type: ignore[return-value] return "kills" @@ -565,6 +582,10 @@ def _build_title(*, metric: str, timeframe: str, server_id: str | None) -> str: metric_label = { "kills": "Top kills", "deaths": "Top muertes", + "teamkills": "Top teamkills", + "matches_considered": "Top partidas jugadas", + "kd_ratio": "Top K/D", + "kills_per_match": "Top kills por partida", "matches_over_100_kills": "Partidas 100+ kills", "support": "Top soporte", }.get(metric, "Top kills") @@ -578,6 +599,69 @@ def _coerce_int(value: object) -> int: return 0 +def _coerce_metric_value(value: object) -> int | float: + numeric = _coerce_float(value) + if numeric.is_integer(): + return int(numeric) + return round(numeric, 2) + + +def _coerce_float(value: object) -> float: + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + +def _resolve_metric_sql(metric: str) -> tuple[str, str, str]: + metric_sql_by_metric = { + "kills": "SUM(COALESCE(stats.kills, 0))", + "deaths": "SUM(COALESCE(stats.deaths, 0))", + "teamkills": "SUM(COALESCE(stats.teamkills, 0))", + "matches_considered": "COUNT(DISTINCT stats.match_key)", + "kd_ratio": ( + "CASE " + "WHEN SUM(COALESCE(stats.deaths, 0)) > 0 " + "THEN ROUND(CAST(SUM(COALESCE(stats.kills, 0)) AS REAL) / SUM(COALESCE(stats.deaths, 0)), 2) " + "ELSE CAST(SUM(COALESCE(stats.kills, 0)) AS REAL) " + "END" + ), + "kills_per_match": ( + "CASE " + "WHEN COUNT(DISTINCT stats.match_key) > 0 " + "THEN ROUND(CAST(SUM(COALESCE(stats.kills, 0)) AS REAL) / COUNT(DISTINCT stats.match_key), 2) " + "ELSE 0.0 " + "END" + ), + "matches_over_100_kills": "SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END)", + } + having_sql_by_metric = { + "kills": "HAVING SUM(COALESCE(stats.kills, 0)) > 0", + "deaths": "HAVING SUM(COALESCE(stats.deaths, 0)) > 0", + "teamkills": "HAVING SUM(COALESCE(stats.teamkills, 0)) > 0", + "matches_considered": "HAVING COUNT(DISTINCT stats.match_key) > 0", + "kd_ratio": "HAVING SUM(COALESCE(stats.kills, 0)) > 0", + "kills_per_match": "HAVING COUNT(DISTINCT stats.match_key) > 0 AND SUM(COALESCE(stats.kills, 0)) > 0", + "matches_over_100_kills": "HAVING SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END) > 0", + } + order_by_sql_by_metric = { + "kills": "metric_value DESC, matches_considered DESC, stats.player_name ASC", + "deaths": "metric_value DESC, matches_considered DESC, stats.player_name ASC", + "teamkills": "metric_value DESC, matches_considered DESC, stats.player_name ASC", + "matches_considered": "metric_value DESC, kills DESC, stats.player_name ASC", + "kd_ratio": "metric_value DESC, kills DESC, matches_considered DESC, stats.player_name ASC", + "kills_per_match": "metric_value DESC, kills DESC, matches_considered DESC, stats.player_name ASC", + "matches_over_100_kills": "metric_value DESC, matches_considered DESC, stats.player_name ASC", + } + if metric == "support": + raise ValueError("support is handled separately") + return ( + metric_sql_by_metric[metric], + having_sql_by_metric[metric], + order_by_sql_by_metric[metric], + ) + + def _parse_datetime(value: object) -> datetime | None: if isinstance(value, datetime): parsed = value diff --git a/backend/app/routes.py b/backend/app/routes.py index 23600aa..83b0b29 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -49,6 +49,15 @@ from .payloads import ( from .rcon_historical_leaderboards import build_rcon_materialized_leaderboard_snapshot_payload from .scoreboard_origins import get_trusted_public_scoreboard_origin +RANKING_METRICS = { + "kills", + "deaths", + "teamkills", + "matches_considered", + "kd_ratio", + "kills_per_match", +} + GET_ROUTES = { "/health": build_health_payload, @@ -118,7 +127,7 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] if timeframe not in {"weekly", "monthly", "annual"}: return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter") metric = params.get("metric", ["kills"])[0] - if metric != "kills": + if metric not in RANKING_METRICS: return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") limit = _parse_limit(parsed.query) if limit is None: diff --git a/docs/global-ranking-page-plan.md b/docs/global-ranking-page-plan.md index 1d1b8a8..6aa9962 100644 --- a/docs/global-ranking-page-plan.md +++ b/docs/global-ranking-page-plan.md @@ -42,6 +42,56 @@ The first Ranking page supports: - filter changes without manual page reload where feasible - clear differentiation between `ready`, `missing`, `empty`, `offline` and request-error states +## V1.1 Metric Expansion Contract + +V1.1 expands `Ranking global` only within the existing RCON-first read paths and annual snapshot boundary. + +Supported metrics: + +- `kills` +- `deaths` +- `teamkills` +- `matches_considered` +- `kd_ratio` +- `kills_per_match` + +Calculation rules: + +- `kills`: `SUM(kills)` +- `deaths`: `SUM(deaths)` +- `teamkills`: `SUM(teamkills)` +- `matches_considered`: `COUNT(DISTINCT match_key)` across closed matches in the selected window +- `kd_ratio`: `SUM(kills) / SUM(deaths)` +- `kills_per_match`: `SUM(kills) / COUNT(DISTINCT match_key)` + +Safe division rules: + +- if `deaths = 0`, `kd_ratio` returns `kills` as a finite display-safe value rather than dividing by zero +- if `matches_considered = 0`, `kills_per_match` returns `0` +- backend should return rounded display-safe decimal values for ratio metrics + +Ordering and tie-break rules: + +- `kills`: order by `kills` desc, `matches_considered` desc, `player_name` asc +- `deaths`: order by `deaths` desc, `matches_considered` desc, `player_name` asc +- `teamkills`: order by `teamkills` desc, `matches_considered` desc, `player_name` asc +- `matches_considered`: order by `matches_considered` desc, `kills` desc, `player_name` asc +- `kd_ratio`: order by `kd_ratio` desc, `kills` desc, `matches_considered` desc, `player_name` asc +- `kills_per_match`: order by `kills_per_match` desc, `kills` desc, `matches_considered` desc, `player_name` asc + +Timeframe support: + +- `weekly`: supports all V1.1 metrics through the existing materialized RCON runtime read model +- `monthly`: supports all V1.1 metrics through the existing materialized RCON runtime read model +- `annual`: remains snapshot-safe first; `kills` is required support and extra annual metrics are allowed only when a precomputed annual snapshot already exists for that metric + +Annual safety boundary: + +- annual requests must remain snapshot-backed +- annual requests must not trigger full-year recomputation in the public request path +- if an annual metric does not have a safe snapshot-backed read path, backend must return controlled `400` instead of degrading to runtime aggregation +- until extra annual snapshots are explicitly implemented, annual support is effectively `kills`-only + ## V1 Non-goals - Elo/MMR @@ -73,6 +123,7 @@ The first Ranking page supports: - Reader: existing weekly/monthly selection logic in `select_leaderboard_window(...)`. - Ordering: metric desc, `matches_considered` desc, `player_name` asc. - V1 metric commitment: `kills`. +- V1.1 metric support: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`. ### Annual @@ -80,13 +131,15 @@ The first Ranking page supports: - Reader: existing annual snapshot loader. - No annual recomputation during public requests. - Missing annual data must surface as a controlled `snapshot_status="missing"` state. +- V1 required annual metric: `kills`. +- Extra annual metrics are out of scope until their snapshot generation and read path are explicitly implemented. ## API Contract Direction ### Dedicated Ranking endpoint ```http -GET /api/ranking?timeframe=weekly|monthly|annual&server_id=&metric=kills&limit=20&year= +GET /api/ranking?timeframe=weekly|monthly|annual&server_id=&metric=&limit=20&year= ``` Purpose: @@ -104,10 +157,13 @@ Query rules: Validation rules: - allowed `timeframe`: `weekly`, `monthly`, `annual` -- V1 allowed `metric`: `kills` +- V1.1 allowed `metric` for weekly/monthly: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match` +- allowed `annual` metric by default: `kills` +- annual extra metrics require an explicit snapshot-backed implementation; otherwise return `400` - allowed `limit`: `1..100` - `year` must be a positive integer when annual is requested - annual requests ignore weekly/monthly runtime window policy and read snapshots only +- unsupported metric, unsupported timeframe or unsupported annual metric must return a controlled request-validation error and must not silently downgrade to `kills` ## Response Contract @@ -118,7 +174,7 @@ Validation rules: "page_kind": "global-ranking", "timeframe": "monthly", "server_id": "all", - "metric": "kills", + "metric": "kills_per_match", "limit": 20, "requested_limit": 20, "window_start": "2026-06-01T00:00:00Z", @@ -137,12 +193,13 @@ Validation rules: "ranking_position": 1, "player_id": "76561198000000000", "player_name": "Rambo", - "metric_value": 421, + "metric_value": 30.07, "matches_considered": 14, "kills": 421, "deaths": 280, "teamkills": 3, - "kd_ratio": 1.5 + "kd_ratio": 1.5, + "kills_per_match": 30.07 } ] } @@ -155,7 +212,7 @@ Common top-level fields: - `timeframe`: `weekly`, `monthly`, `annual` - `server_id`: `all` or one supported active server id -- `metric`: `kills` in V1 +- `metric`: requested supported metric - `limit`: effective served limit - `requested_limit`: requested client limit - `snapshot_status`: always present for frontend state handling @@ -172,6 +229,13 @@ Required item fields: - `deaths` - `teamkills` - `kd_ratio` +- `kills_per_match` + +Conditional item rules: + +- `metric_value` may be integer or rounded decimal depending on the requested metric +- `kills_per_match` should be present when that metric is supported in the response contract +- `matches_considered` remains required because it is both a displayed field and a common tie-break input Weekly/monthly-only metadata: @@ -207,6 +271,8 @@ Annual-only metadata: - Read only from annual snapshots. - `snapshot_status="ready"` with `items=[]` is a valid empty-ready state. - `snapshot_status="missing"` is not a backend crash and must be rendered distinctly. +- Annual must not pretend support for metrics that do not have precomputed snapshots. +- If client requests an unsupported annual metric, backend should return `400` rather than an empty-ready payload. ## UI State Contract @@ -226,6 +292,7 @@ Expected rendering behavior: - no data keeps filters visible and explains that there are no rows for the active scope - annual snapshot missing explains that the annual snapshot has not been generated yet - unsupported metric does not silently downgrade to another metric +- unsupported annual metric explains that the metric is not available for annual snapshots yet - controlled error does not break page navigation or filter controls ## Backend Behavior Requirements @@ -235,6 +302,24 @@ Expected rendering behavior: - Do not expand annual generation logic inside request handling. - Keep server scope limited to active supported scopes: `all`, `comunidad-hispana-01`, `comunidad-hispana-02`. - Do not surface Comunidad Hispana #03 in defaults, options or backend read scope. +- Do not add large new tables or a second ranking architecture for V1.1 metric expansion. + +## Payload Adjustment Notes + +- The existing `metric_value` field remains the primary sortable/display value for the active metric. +- Weekly and monthly responses should keep the existing totals payload and may add `kills_per_match` when the backend serves that metric. +- Annual responses should keep the same payload shape and only expand metrics when the snapshot schema already stores the required values safely. +- Client code should continue to rely on explicit `metric` and `snapshot_status` fields rather than inferring support from item contents alone. + +## V1.1 Non-goals + +- Elo/MMR +- public scoreboard as primary ranking source +- annual runtime full-year recomputation +- large new tables +- advanced charts +- authentication +- Comunidad Hispana #03 ## Frontend Integration Guidance diff --git a/frontend/assets/css/styles.css b/frontend/assets/css/styles.css index 1b81f4c..f029576 100644 --- a/frontend/assets/css/styles.css +++ b/frontend/assets/css/styles.css @@ -1246,6 +1246,21 @@ h2 { margin-top: 0; } +.ranking-form__note { + margin: 0; + padding: 0.8rem 1rem; + border: 1px solid rgba(159, 168, 141, 0.18); + border-radius: 14px; + background: rgba(15, 18, 13, 0.62); + color: var(--text-soft); + line-height: 1.65; +} + +.ranking-form__note--warning { + border-color: rgba(210, 182, 118, 0.3); + color: #e2d7a9; +} + .ranking-select { width: 100%; min-height: 52px; @@ -1293,6 +1308,12 @@ h2 { text-transform: uppercase; } +.ranking-meta-card--active { + border-color: rgba(183, 201, 125, 0.28); + background: + linear-gradient(180deg, rgba(36, 44, 31, 0.92), rgba(14, 18, 12, 0.98)); +} + .ranking-table-shell { overflow-x: auto; } @@ -1326,6 +1347,11 @@ h2 { background: rgba(183, 201, 125, 0.05); } +.ranking-table__metric { + color: var(--accent-warm); + font-weight: 700; +} + .ranking-player { display: grid; gap: 4px; diff --git a/frontend/assets/js/ranking.js b/frontend/assets/js/ranking.js index 1d37396..04ae61b 100644 --- a/frontend/assets/js/ranking.js +++ b/frontend/assets/js/ranking.js @@ -13,16 +13,41 @@ document.addEventListener("DOMContentLoaded", () => { const metaNode = document.getElementById("ranking-meta"); const tableNode = document.getElementById("ranking-table"); const tableBodyNode = document.getElementById("ranking-table-body"); + const metricHeadingNode = document.getElementById("ranking-metric-heading"); const emptyNode = document.getElementById("ranking-empty"); + const filterNoteNode = document.getElementById("ranking-filter-note"); const currentYear = new Date().getUTCFullYear(); + const annualMetric = "kills"; + const defaultMetric = "kills"; + const defaultLimit = "20"; + const defaultTimeframe = "weekly"; + const defaultServerId = "all"; + const supportedMetrics = [ + "kills", + "deaths", + "teamkills", + "matches_considered", + "kd_ratio", + "kills_per_match", + ]; + const supportedTimeframes = ["weekly", "monthly", "annual"]; + const supportedServerIds = [ + "all", + "comunidad-hispana-01", + "comunidad-hispana-02", + ]; + const supportedLimits = ["5", "10", "20", "50", "100"]; + let isBackendOnline = false; if (yearInput) { yearInput.value = String(currentYear); } + applyInitialUrlState(); toggleYearField(); + syncMetricState(); setBackendState("Comprobando disponibilidad del backend", false); setRankingState("neutral", "Esperando filtros para cargar el ranking global."); clearRankingSurface(); @@ -31,6 +56,8 @@ document.addEventListener("DOMContentLoaded", () => { if (timeframeSelect) { timeframeSelect.addEventListener("change", () => { toggleYearField(); + syncMetricState(); + updateUrlState(); void loadRanking(); }); } @@ -40,13 +67,25 @@ document.addEventListener("DOMContentLoaded", () => { return; } node.addEventListener("change", () => { + syncMetricState(); + updateUrlState(); void loadRanking(); }); }); + if (yearInput) { + yearInput.addEventListener("change", () => { + updateUrlState(); + if (String(timeframeSelect?.value || defaultTimeframe) === "annual") { + void loadRanking(); + } + }); + } + if (form) { form.addEventListener("submit", (event) => { event.preventDefault(); + updateUrlState(); void loadRanking(); }); } @@ -69,6 +108,57 @@ document.addEventListener("DOMContentLoaded", () => { stateNode.className = `stats-state stats-state--${state}`; } + function setFilterNote(message, tone = "neutral") { + if (!filterNoteNode) { + return; + } + filterNoteNode.textContent = message; + filterNoteNode.className = `ranking-form__note ranking-form__note--${tone}`; + } + + function applyInitialUrlState() { + const params = new URLSearchParams(window.location.search); + const timeframe = params.get("timeframe"); + const serverId = params.get("server_id") || params.get("server"); + const metric = params.get("metric"); + const limit = params.get("limit"); + const year = params.get("year"); + + if (timeframeSelect) { + timeframeSelect.value = supportedTimeframes.includes(timeframe || "") + ? String(timeframe) + : defaultTimeframe; + } + if (serverSelect) { + serverSelect.value = supportedServerIds.includes(serverId || "") + ? String(serverId) + : defaultServerId; + } + if (metricSelect) { + metricSelect.value = supportedMetrics.includes(metric || "") + ? String(metric) + : defaultMetric; + } + if (limitSelect) { + limitSelect.value = supportedLimits.includes(limit || "") + ? String(limit) + : defaultLimit; + } + if (yearInput && year) { + const normalizedYear = Number.parseInt(year, 10); + if (Number.isFinite(normalizedYear) && normalizedYear > 0) { + yearInput.value = String(normalizedYear); + } + } + + if (params.has("limit") && !supportedLimits.includes(limit || "")) { + setRankingState( + "warning", + "El limite del URL no es valido para esta interfaz. Se restauro el valor permitido por defecto.", + ); + } + } + function toggleYearField() { const isAnnual = timeframeSelect?.value === "annual"; if (yearWrap) { @@ -79,6 +169,47 @@ document.addEventListener("DOMContentLoaded", () => { } } + function syncMetricState() { + const isAnnual = timeframeSelect?.value === "annual"; + if (!metricSelect) { + return; + } + + Array.from(metricSelect.options).forEach((option) => { + option.disabled = isAnnual && option.value !== annualMetric; + }); + + if (isAnnual && metricSelect.value !== annualMetric) { + metricSelect.value = annualMetric; + } + + if (isAnnual) { + setFilterNote( + "El ranking anual sigue limitado a kills porque solo esa metrica tiene lectura snapshot segura.", + "warning", + ); + return; + } + + setFilterNote( + "Ranking compara top globales. Para buscar un jugador concreto usa Stats.", + "neutral", + ); + } + + function updateUrlState() { + const searchParams = new URLSearchParams({ + timeframe: String(timeframeSelect?.value || defaultTimeframe), + server_id: String(serverSelect?.value || defaultServerId), + metric: String(metricSelect?.value || defaultMetric), + limit: String(limitSelect?.value || defaultLimit), + }); + if (searchParams.get("timeframe") === "annual") { + searchParams.set("year", String(yearInput?.value || currentYear)); + } + window.history.replaceState({}, "", `?${searchParams.toString()}`); + } + function clearRankingSurface() { if (metaNode) { metaNode.innerHTML = ""; @@ -115,7 +246,12 @@ document.addEventListener("DOMContentLoaded", () => { throw new Error("Unexpected health payload"); } setBackendState("Backend operativo", true); - setRankingState("neutral", "Backend disponible. Ajusta filtros o usa la lectura inicial."); + if (!String(stateNode?.textContent || "").includes("limite del URL")) { + setRankingState( + "neutral", + "Backend disponible. Ajusta filtros o usa la lectura inicial.", + ); + } void loadRanking(); } catch (error) { console.warn("Ranking health check failed", error); @@ -128,10 +264,10 @@ document.addEventListener("DOMContentLoaded", () => { } async function loadRanking() { - const timeframe = String(timeframeSelect?.value || "weekly"); - const serverId = String(serverSelect?.value || "all"); - const metric = String(metricSelect?.value || "kills"); - const limit = String(limitSelect?.value || "20"); + const timeframe = String(timeframeSelect?.value || defaultTimeframe); + const serverId = String(serverSelect?.value || defaultServerId); + const metric = String(metricSelect?.value || defaultMetric); + const limit = String(limitSelect?.value || defaultLimit); if (!isBackendOnline) { setRankingState("error", "Backend no disponible. El ranking queda en estado offline."); @@ -168,7 +304,9 @@ document.addEventListener("DOMContentLoaded", () => { const response = await fetch(`${backendBaseUrl}/api/ranking?${searchParams.toString()}`); if (!response.ok) { const errorPayload = await safeParseJson(response); - const errorMessage = String(errorPayload?.message || errorPayload?.detail || "").toLowerCase(); + const errorMessage = String( + errorPayload?.message || errorPayload?.detail || "", + ).toLowerCase(); handleRequestError(response.status, errorMessage, timeframe); return; } @@ -191,10 +329,28 @@ document.addEventListener("DOMContentLoaded", () => { function handleRequestError(statusCode, errorMessage, timeframe) { const normalizedMessage = String(errorMessage || ""); - if (statusCode === 400 && normalizedMessage.includes("metric")) { - setRankingState("warning", "La metrica solicitada no esta soportada en V1."); + if (statusCode === 400 && normalizedMessage.includes("limit")) { + setRankingState("warning", "El limite solicitado no es valido."); renderEmptyState( - "Solo la metrica kills esta disponible en esta primera version del ranking global.", + "Usa un limite permitido por la interfaz o por el backend. Esta vista admite Top 5, 10, 20, 50 y 100.", + ); + return; + } + if ( + statusCode === 400 && + normalizedMessage.includes("metric") && + normalizedMessage.includes("annual") + ) { + setRankingState("warning", "El ranking anual solo admite kills por ahora."); + renderEmptyState( + "Las metricas extra estan disponibles en semanal y mensual. El ranking anual sigue limitado a kills mientras no existan snapshots seguros adicionales.", + ); + return; + } + if (statusCode === 400 && normalizedMessage.includes("metric")) { + setRankingState("warning", "La metrica solicitada no esta soportada."); + renderEmptyState( + "Usa kills, deaths, teamkills, partidas jugadas, K/D o kills por partida.", ); return; } @@ -208,11 +364,12 @@ document.addEventListener("DOMContentLoaded", () => { renderEmptyState("Usa una ventana semanal, mensual o anual."); return; } - if (timeframe === "annual") { - setRankingState("error", "Error controlado al cargar el ranking anual."); - } else { - setRankingState("error", "Error controlado al cargar el ranking."); - } + setRankingState( + "error", + timeframe === "annual" + ? "Error controlado al cargar el ranking anual." + : "Error controlado al cargar el ranking.", + ); renderEmptyState( "La consulta devolvio un error controlado. Ajusta los filtros y vuelve a intentarlo.", ); @@ -220,16 +377,20 @@ document.addEventListener("DOMContentLoaded", () => { function renderRanking(data) { const items = Array.isArray(data.items) ? data.items : []; - const timeframe = String(data.timeframe || "weekly"); - const serverId = String(data.server_id || "all"); - const metric = String(data.metric || "kills"); + const timeframe = String(data.timeframe || defaultTimeframe); + const serverId = String(data.server_id || defaultServerId); + const metric = String(data.metric || defaultMetric); const snapshotStatus = String(data.snapshot_status || "").toLowerCase(); if (titleNode) { titleNode.textContent = - timeframe === "annual" ? "Top anual activo" : "Tabla activa del alcance seleccionado"; + timeframe === "annual" + ? `Top anual por ${labelForMetric(metric)}` + : `Tabla activa por ${labelForMetric(metric)}`; + } + if (metricHeadingNode) { + metricHeadingNode.textContent = labelForMetric(metric); } - if (metaNode) { metaNode.innerHTML = buildMetaMarkup(data); } @@ -259,11 +420,11 @@ document.addEventListener("DOMContentLoaded", () => { setRankingState( "ready", - `Ranking ${labelForTimeframe(timeframe)} listo para ${labelForServer(serverId)} por ${metric}.`, + `${labelForMetric(metric)} listo en ${labelForTimeframe(timeframe)} para ${labelForServer(serverId)}.`, ); if (tableBodyNode) { - tableBodyNode.innerHTML = items.map(renderRow).join(""); + tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join(""); } if (tableNode) { tableNode.hidden = false; @@ -275,23 +436,38 @@ document.addEventListener("DOMContentLoaded", () => { function buildMetaMarkup(data) { const source = data.source && typeof data.source === "object" ? data.source : {}; - const parts = [ - `

Servidor

${escapeHtml(labelForServer(data.server_id))}
`, - `

Ventana

${escapeHtml(labelForWindow(data))}
`, - `

Fuente

${escapeHtml(String(source.read_model || "No disponible"))}
`, - `

Actualizado

${escapeHtml(formatDateTime(source.generated_at))}
`, + const timeframe = String(data.timeframe || defaultTimeframe); + const metric = String(data.metric || defaultMetric); + const cards = [ + { label: "Periodo activo", value: labelForTimeframe(timeframe), active: true }, + { label: "Servidor activo", value: labelForServer(data.server_id), active: true }, + { label: "Metrica activa", value: labelForMetric(metric), active: true }, + { label: "Limite", value: `Top ${safeInt(data.limit, safeInt(defaultLimit, 20))}` }, + { label: "Ventana", value: labelForWindow(data) }, + { label: "Fuente", value: String(source.read_model || "No disponible") }, + { label: "Actualizado", value: formatDateTime(source.generated_at) }, ]; - if (String(data.timeframe || "") === "annual") { - parts.push( - `

Snapshot

${escapeHtml(String(data.snapshot_status || "missing"))}
`, - ); + if (timeframe === "annual") { + cards.push({ + label: "Snapshot", + value: String(data.snapshot_status || "missing"), + }); } - return parts.join(""); + return cards + .map( + (card) => ` +
+

${escapeHtml(card.label)}

+ ${escapeHtml(String(card.value || "No disponible"))} +
+ `, + ) + .join(""); } - function renderRow(item) { + function renderRow(item, metric) { return ` #${safeInt(item.ranking_position, 0)} @@ -301,11 +477,13 @@ document.addEventListener("DOMContentLoaded", () => { ${escapeHtml(String(item.player_id || "Sin ID"))} - ${safeInt(item.metric_value, 0)} - ${safeInt(item.matches_considered, 0)} + ${formatMetricValue(item.metric_value, metric)} + ${safeInt(item.kills, 0)} ${safeInt(item.deaths, 0)} ${safeInt(item.teamkills, 0)} + ${safeInt(item.matches_considered, 0)} ${safeDecimal(item.kd_ratio, 2, "0.00")} + ${safeDecimal(item.kills_per_match, 2, "0.00")} `; } @@ -337,12 +515,31 @@ document.addEventListener("DOMContentLoaded", () => { return "semanal"; } + function labelForMetric(metric) { + const labels = { + kills: "Kills", + deaths: "Deaths", + teamkills: "Teamkills", + matches_considered: "Partidas jugadas", + kd_ratio: "K/D", + kills_per_match: "Kills por partida", + }; + return labels[metric] || "Kills"; + } + + function formatMetricValue(value, metric) { + if (metric === "kd_ratio" || metric === "kills_per_match") { + return safeDecimal(value, 2, "0.00"); + } + return safeInt(value, 0).toLocaleString("es-ES"); + } + function safeInt(value, fallback) { const parsed = Number(value); if (!Number.isFinite(parsed)) { return fallback; } - return parsed; + return Math.trunc(parsed); } function safeDecimal(value, maximumFractionDigits, fallback) { diff --git a/frontend/ranking.html b/frontend/ranking.html index e5547ae..7fac030 100644 --- a/frontend/ranking.html +++ b/frontend/ranking.html @@ -22,9 +22,9 @@ Global

- Consulta los lideres publicos por kills con lectura RCON-first, cambia - periodo y servidor sin salir de la pagina, y usa Stats solo cuando - necesites revisar el rendimiento de un jugador concreto. + Consulta los lideres publicos por metrica con lectura RCON-first, + cambia periodo y servidor sin salir de la pagina, y deja Stats para + revisar el rendimiento de un jugador concreto.

Comprobando disponibilidad del backend @@ -76,15 +76,22 @@ +

+ Ranking compara top globales. Para buscar un jugador concreto usa Stats. +

@@ -127,11 +137,13 @@ Pos. Jugador + Valor activo Kills - Partidas Deaths Teamkills + Partidas K/D + Kills/partida diff --git a/frontend/stats.html b/frontend/stats.html index d242a0d..00a81aa 100644 --- a/frontend/stats.html +++ b/frontend/stats.html @@ -32,6 +32,9 @@ Volver al inicio + + Ranking + Historico diff --git a/scripts/run-stats-validation.ps1 b/scripts/run-stats-validation.ps1 index bf01337..82a7be4 100644 --- a/scripts/run-stats-validation.ps1 +++ b/scripts/run-stats-validation.ps1 @@ -49,9 +49,13 @@ function Assert-LastExitCode { Assert-FileExists "frontend/stats.html" "Missing frontend/stats.html" Assert-FileExists "frontend/assets/js/stats.js" "Missing frontend/assets/js/stats.js" +Assert-FileExists "frontend/ranking.html" "Missing frontend/ranking.html" +Assert-FileExists "frontend/assets/js/ranking.js" "Missing frontend/assets/js/ranking.js" $statsHtml = Get-Content -Raw "frontend/stats.html" $statsJs = Get-Content -Raw "frontend/assets/js/stats.js" +$rankingHtml = Get-Content -Raw "frontend/ranking.html" +$rankingJs = Get-Content -Raw "frontend/assets/js/ranking.js" Assert-ContainsText $statsHtml 'id="stats-search-form"' ` "Stats page no longer exposes the player search form." @@ -71,11 +75,34 @@ Assert-ContainsText $statsHtml 'id="stats-annual-state"' ` "Stats page no longer exposes annual ranking state node." Assert-ContainsText $statsHtml 'id="stats-backend-state"' ` "Stats page no longer exposes backend state chip." +Assert-ContainsText $statsHtml 'href="./ranking.html"' ` + "Stats page should keep a direct link to ranking." Assert-ContainsText $statsJs 'getElementById("stats-search-form")' ` "Stats JS no longer sets up search form lookup." Assert-ContainsText $statsJs "loadPlayerProfile(" ` "Stats JS no longer defines loadPlayerProfile." +Assert-ContainsText $rankingHtml 'id="ranking-form"' ` + "Ranking page no longer exposes the ranking filter form." +Assert-ContainsText $rankingHtml 'value="kills_per_match"' ` + "Ranking page should expose the kills_per_match option." +Assert-ContainsText $rankingHtml 'value="matches_considered"' ` + "Ranking page should expose the matches_considered option." +Assert-ContainsText $rankingHtml 'id="ranking-filter-note"' ` + "Ranking page should expose the ranking filter guidance note." +Assert-ContainsText $rankingHtml 'id="ranking-metric-heading"' ` + "Ranking page should expose the dynamic metric heading." +Assert-ContainsText $rankingJs "applyInitialUrlState" ` + "Ranking JS should restore filter state from the URL." +Assert-ContainsText $rankingJs "history.replaceState" ` + "Ranking JS should sync filter state into the URL." +Assert-ContainsText $rankingJs "El ranking anual sigue limitado a kills" ` + "Ranking JS should explain the annual kills-only constraint." +Assert-ContainsText $rankingJs "El limite solicitado no es valido." ` + "Ranking JS should expose a dedicated invalid-limit message." +Assert-ContainsText $rankingJs "/api/ranking" ` + "Ranking frontend no longer targets the ranking endpoint." + Assert-ContainsText $statsJs "/api/stats/players/search" ` "Stats frontend no longer targets the player search endpoint." Assert-ContainsText $statsJs "/api/stats/rankings/annual" ` @@ -114,6 +141,10 @@ def require_str(value, message): require(isinstance(value, str), message) +def require_number(value, message): + require(isinstance(value, (int, float)), message) + + health_status, health_payload = read_payload("/health") require(health_status == 200, "Route resolver /health should return 200.") require(health_payload.get("status") == "ok", "/health payload should be ok.") @@ -214,6 +245,36 @@ require(weekly_ranking_data.get("snapshot_status") == "ready", "Global ranking w require(isinstance(weekly_ranking_data.get("items"), list), "Global ranking weekly items must be list.") require(isinstance(weekly_ranking_data.get("source"), dict), "Global ranking weekly should expose source metadata.") +weekly_deaths_status, weekly_deaths_payload = read_payload( + "/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20" +) +require(weekly_deaths_status == 200, "Global ranking weekly deaths route should return 200.") +require((weekly_deaths_payload.get("data") or {}).get("metric") == "deaths", "Global ranking weekly deaths metric should be preserved.") + +weekly_teamkills_status, weekly_teamkills_payload = read_payload( + "/api/ranking?timeframe=weekly&server_id=all&metric=teamkills&limit=20" +) +require(weekly_teamkills_status == 200, "Global ranking weekly teamkills route should return 200.") +require((weekly_teamkills_payload.get("data") or {}).get("metric") == "teamkills", "Global ranking weekly teamkills metric should be preserved.") + +weekly_matches_status, weekly_matches_payload = read_payload( + "/api/ranking?timeframe=weekly&server_id=all&metric=matches_considered&limit=20" +) +require(weekly_matches_status == 200, "Global ranking weekly matches_considered route should return 200.") +require((weekly_matches_payload.get("data") or {}).get("metric") == "matches_considered", "Global ranking weekly matches_considered metric should be preserved.") + +weekly_kd_status, weekly_kd_payload = read_payload( + "/api/ranking?timeframe=weekly&server_id=all&metric=kd_ratio&limit=20" +) +require(weekly_kd_status == 200, "Global ranking weekly kd_ratio route should return 200.") +require((weekly_kd_payload.get("data") or {}).get("metric") == "kd_ratio", "Global ranking weekly kd_ratio metric should be preserved.") + +weekly_kpm_status, weekly_kpm_payload = read_payload( + "/api/ranking?timeframe=weekly&server_id=all&metric=kills_per_match&limit=20" +) +require(weekly_kpm_status == 200, "Global ranking weekly kills_per_match route should return 200.") +require((weekly_kpm_payload.get("data") or {}).get("metric") == "kills_per_match", "Global ranking weekly kills_per_match metric should be preserved.") + monthly_ranking_status, monthly_ranking_payload = read_payload( "/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kills&limit=20" ) @@ -222,6 +283,18 @@ monthly_ranking_data = monthly_ranking_payload.get("data") or {} require(monthly_ranking_data.get("timeframe") == "monthly", "Global ranking monthly timeframe should be preserved.") require(monthly_ranking_data.get("server_id") == "comunidad-hispana-01", "Global ranking monthly should preserve server_id.") +monthly_kd_status, monthly_kd_payload = read_payload( + "/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kd_ratio&limit=20" +) +require(monthly_kd_status == 200, "Global ranking monthly kd_ratio route should return 200.") +require((monthly_kd_payload.get("data") or {}).get("metric") == "kd_ratio", "Global ranking monthly kd_ratio metric should be preserved.") + +monthly_kpm_status, monthly_kpm_payload = read_payload( + "/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kills_per_match&limit=20" +) +require(monthly_kpm_status == 200, "Global ranking monthly kills_per_match route should return 200.") +require((monthly_kpm_payload.get("data") or {}).get("metric") == "kills_per_match", "Global ranking monthly kills_per_match metric should be preserved.") + annual_ranking_status, annual_ranking_payload = read_payload( f"/api/ranking?timeframe=annual&year={current_year}&server_id=all&metric=kills&limit=20" ) @@ -232,6 +305,29 @@ require(annual_ranking_data.get("metric") == "kills", "Global ranking annual met require(annual_ranking_data.get("snapshot_status") in {"ready", "missing"}, "Global ranking annual snapshot_status should be ready or missing.") require(isinstance(annual_ranking_data.get("items"), list), "Global ranking annual items must be list.") +for ranking_payload in [ + weekly_ranking_payload, + weekly_deaths_payload, + weekly_teamkills_payload, + weekly_matches_payload, + weekly_kd_payload, + weekly_kpm_payload, + monthly_kd_payload, + monthly_kpm_payload, + annual_ranking_payload, +]: + ranking_data = ranking_payload.get("data") or {} + for item in ranking_data.get("items", []): + if item is None: + continue + require_int(item.get("ranking_position"), "Global ranking item ranking_position should be int.") + require_str(item.get("player_id"), "Global ranking item should include player_id.") + require_str(item.get("player_name"), "Global ranking item should include player_name.") + require_number(item.get("metric_value"), "Global ranking item metric_value should be numeric.") + require_int(item.get("matches_considered"), "Global ranking item matches_considered should be int.") + require_number(item.get("kd_ratio"), "Global ranking item kd_ratio should be numeric.") + require_number(item.get("kills_per_match"), "Global ranking item kills_per_match should be numeric.") + low_limit_ranking_status, low_limit_ranking_payload = read_payload( "/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=3" ) @@ -244,10 +340,23 @@ high_limit_ranking_status, _ = read_payload( require(high_limit_ranking_status == 400, "Global ranking with limit=101 should return 400.") unsupported_metric_ranking_status, _ = read_payload( - "/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20" + "/api/ranking?timeframe=weekly&server_id=all&metric=assists&limit=20" ) require(unsupported_metric_ranking_status == 400, "Global ranking with unsupported metric should return 400.") +annual_unsupported_metric_status, annual_unsupported_metric_payload = read_payload( + f"/api/ranking?timeframe=annual&year={current_year}&server_id=all&metric=deaths&limit=20" +) +require(annual_unsupported_metric_status == 400, "Global ranking annual with unsupported snapshot metric should return 400.") +require( + "annual" in str( + annual_unsupported_metric_payload.get("message") + or annual_unsupported_metric_payload.get("error") + or "" + ).lower(), + "Annual unsupported metric error should mention annual snapshot support.", +) + unsupported_timeframe_ranking_status, _ = read_payload( "/api/ranking?timeframe=seasonal&server_id=all&metric=kills&limit=20" ) @@ -324,6 +433,21 @@ if ($backendAvailable) { throw "Live global ranking weekly route should return status=ok." } + $rankingWeeklyDeathsPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20" -TimeoutSec 5 + if ($rankingWeeklyDeathsPayload.status -ne "ok") { + throw "Live global ranking weekly deaths route should return status=ok." + } + + $rankingWeeklyKdPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=kd_ratio&limit=20" -TimeoutSec 5 + if ($rankingWeeklyKdPayload.status -ne "ok") { + throw "Live global ranking weekly kd_ratio route should return status=ok." + } + + $rankingMonthlyKpmPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/ranking?timeframe=monthly&server_id=all&metric=kills_per_match&limit=20" -TimeoutSec 5 + if ($rankingMonthlyKpmPayload.status -ne "ok") { + throw "Live global ranking monthly kills_per_match route should return status=ok." + } + $rankingAnnualPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/ranking?timeframe=annual&year=$currentYear&server_id=all&metric=kills&limit=20" -TimeoutSec 5 if ($rankingAnnualPayload.status -ne "ok") { throw "Live global ranking annual route should return status=ok." @@ -332,7 +456,22 @@ if ($backendAvailable) { throw "Live global ranking annual should expose ready/missing snapshot status." } - $rankingUnsupportedMetricStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20" + $rankingSupportedDeathsStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=deaths&limit=20" + if ($rankingSupportedDeathsStatus -ne 200) { + throw "Live global ranking weekly deaths should return HTTP 200." + } + + $rankingInvalidMetricStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=assists&limit=20" + if ($rankingInvalidMetricStatus -ne 400) { + throw "Live global ranking with invalid metric should return HTTP 400." + } + + $rankingUnsupportedAnnualMetricStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/ranking?timeframe=annual&year=$currentYear&server_id=all&metric=deaths&limit=20" + if ($rankingUnsupportedAnnualMetricStatus -ne 400) { + throw "Live global ranking annual with unsupported snapshot metric should return HTTP 400." + } + + $rankingUnsupportedMetricStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/ranking?timeframe=weekly&server_id=all&metric=assists&limit=20" if ($rankingUnsupportedMetricStatus -ne 400) { throw "Live global ranking with unsupported metric should return HTTP 400." }