Add ranking metric expansion
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-184-define-ranking-metric-expansion-contract
|
id: TASK-184-define-ranking-metric-expansion-contract
|
||||||
title: Define ranking metric expansion contract
|
title: Define ranking metric expansion contract
|
||||||
status: pending
|
status: done
|
||||||
type: documentation
|
type: documentation
|
||||||
team: Analista
|
team: Analista
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -102,17 +102,73 @@ Before completing the task ensure:
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Document:
|
Supported Ranking V1.1 metrics documented in `docs/global-ranking-page-plan.md`:
|
||||||
|
|
||||||
- supported Ranking V1.1 metrics
|
- `kills`
|
||||||
- formula for each metric
|
- `deaths`
|
||||||
- ordering and tie-break expectations
|
- `teamkills`
|
||||||
- supported timeframes by metric
|
- `matches_considered`
|
||||||
- annual snapshot limitations and safety rules
|
- `kd_ratio`
|
||||||
- expected payload adjustments, if any
|
- `kills_per_match`
|
||||||
- controlled error behavior for unsupported metrics
|
|
||||||
- validation performed
|
Formula and aggregation rules documented:
|
||||||
- explicit note that no automated tests apply if this remains documentation-only
|
|
||||||
|
- `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
|
## Change Budget
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-185-add-ranking-extra-metrics-backend-support
|
id: TASK-185-add-ranking-extra-metrics-backend-support
|
||||||
title: Add ranking extra metrics backend support
|
title: Add ranking extra metrics backend support
|
||||||
status: pending
|
status: done
|
||||||
type: backend
|
type: backend
|
||||||
team: Backend Senior
|
team: Backend Senior
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -105,14 +105,72 @@ Before completing the task ensure:
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Document:
|
Metrics added for `GET /api/ranking` weekly/monthly reads:
|
||||||
|
|
||||||
- metrics added
|
- `kills`
|
||||||
- formulas applied in backend ranking logic
|
- `deaths`
|
||||||
- annual metric behavior and limitations
|
- `teamkills`
|
||||||
- validations executed
|
- `matches_considered`
|
||||||
- known limitations
|
- `kd_ratio`
|
||||||
- recommended next task: expose the new metrics safely in Ranking frontend UX
|
- `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
|
## Change Budget
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-186-polish-ranking-metric-ux-and-limits
|
id: TASK-186-polish-ranking-metric-ux-and-limits
|
||||||
title: Polish ranking metric UX and limits
|
title: Polish ranking metric UX and limits
|
||||||
status: pending
|
status: done
|
||||||
type: frontend
|
type: frontend
|
||||||
team: Frontend Senior
|
team: Frontend Senior
|
||||||
supporting_teams:
|
supporting_teams:
|
||||||
@@ -101,14 +101,88 @@ Before completing the task ensure:
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Document:
|
Metrics exposed in Ranking UI:
|
||||||
|
|
||||||
- metrics exposed in UI
|
- `kills`
|
||||||
- annual behavior in the UX
|
- `deaths`
|
||||||
- UI limit choices
|
- `teamkills`
|
||||||
- error states covered
|
- `matches_considered`
|
||||||
- validations executed
|
- `kd_ratio`
|
||||||
- recommended follow-ups, if any
|
- `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
|
## Change Budget
|
||||||
|
|
||||||
@@ -500,22 +500,39 @@ def _normalize_global_ranking_items(items: object) -> list[dict[str, object]]:
|
|||||||
for item in items if isinstance(items, list) else []:
|
for item in items if isinstance(items, list) else []:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
|
matches_considered = int(item.get("matches_considered") or 0)
|
||||||
|
kills = int(item.get("kills") or 0)
|
||||||
normalized_items.append(
|
normalized_items.append(
|
||||||
{
|
{
|
||||||
"ranking_position": int(item.get("ranking_position") or 0),
|
"ranking_position": int(item.get("ranking_position") or 0),
|
||||||
"player_id": item.get("player_id"),
|
"player_id": item.get("player_id"),
|
||||||
"player_name": item.get("player_name"),
|
"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),
|
"matches_considered": matches_considered,
|
||||||
"kills": int(item.get("kills") or 0),
|
"kills": kills,
|
||||||
"deaths": int(item.get("deaths") or 0),
|
"deaths": int(item.get("deaths") or 0),
|
||||||
"teamkills": int(item.get("teamkills") or 0),
|
"teamkills": int(item.get("teamkills") or 0),
|
||||||
"kd_ratio": float(item.get("kd_ratio") or 0.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
|
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:
|
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
|
||||||
|
|
||||||
@@ -735,12 +752,25 @@ def build_annual_ranking_snapshot_payload(
|
|||||||
"ranking_position": int(item.get("ranking_position") or 0),
|
"ranking_position": int(item.get("ranking_position") or 0),
|
||||||
"player_id": item.get("player_id"),
|
"player_id": item.get("player_id"),
|
||||||
"player_name": item.get("player_name"),
|
"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),
|
"matches_considered": int(item.get("matches_considered") or 0),
|
||||||
"kills": int(item.get("kills") or 0),
|
"kills": int(item.get("kills") or 0),
|
||||||
"deaths": int(item.get("deaths") or 0),
|
"deaths": int(item.get("deaths") or 0),
|
||||||
"teamkills": int(item.get("teamkills") or 0),
|
"teamkills": int(item.get("teamkills") or 0),
|
||||||
"kd_ratio": float(item.get("kd_ratio") or 0.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
|
for item in items
|
||||||
if isinstance(item, dict)
|
if isinstance(item, dict)
|
||||||
|
|||||||
@@ -205,7 +205,9 @@ def _normalize_server_key(server_key: str | None) -> str:
|
|||||||
def _normalize_metric(metric: str) -> str:
|
def _normalize_metric(metric: str) -> str:
|
||||||
normalized = str(metric or "kills").strip().lower()
|
normalized = str(metric or "kills").strip().lower()
|
||||||
if normalized != "kills":
|
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
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,16 @@ from .rcon_admin_log_materialization import (
|
|||||||
from .sqlite_utils import connect_sqlite_readonly
|
from .sqlite_utils import connect_sqlite_readonly
|
||||||
|
|
||||||
LeaderboardTimeframe = Literal["weekly", "monthly"]
|
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(
|
def build_rcon_materialized_leaderboard_snapshot_payload(
|
||||||
@@ -197,12 +206,7 @@ def _fetch_leaderboard_rows(
|
|||||||
window_end: datetime,
|
window_end: datetime,
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
scope_sql, scope_params = _build_scope_sql(server_key)
|
scope_sql, scope_params = _build_scope_sql(server_key)
|
||||||
metric_sql = {
|
metric_sql, having_sql, order_by_sql = _resolve_metric_sql(metric)
|
||||||
"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"
|
|
||||||
params: list[object] = [
|
params: list[object] = [
|
||||||
_to_iso(window_start),
|
_to_iso(window_start),
|
||||||
_to_iso(window_end),
|
_to_iso(window_end),
|
||||||
@@ -230,7 +234,7 @@ def _fetch_leaderboard_rows(
|
|||||||
AND TRIM(COALESCE(stats.player_name, '')) != ''
|
AND TRIM(COALESCE(stats.player_name, '')) != ''
|
||||||
GROUP BY stats.player_id, stats.player_name
|
GROUP BY stats.player_id, stats.player_name
|
||||||
{having_sql}
|
{having_sql}
|
||||||
ORDER BY metric_value DESC, matches_considered DESC, stats.player_name ASC
|
ORDER BY {order_by_sql}
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
[MATCH_RESULT_SOURCE, *params],
|
[MATCH_RESULT_SOURCE, *params],
|
||||||
@@ -431,6 +435,9 @@ def _count_matches(
|
|||||||
def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]:
|
def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]:
|
||||||
kills = _coerce_int(row.get("kills"))
|
kills = _coerce_int(row.get("kills"))
|
||||||
deaths = _coerce_int(row.get("deaths"))
|
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 {
|
return {
|
||||||
"ranking_position": index,
|
"ranking_position": index,
|
||||||
"player": {
|
"player": {
|
||||||
@@ -439,12 +446,13 @@ def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]:
|
|||||||
},
|
},
|
||||||
"player_id": row.get("player_id"),
|
"player_id": row.get("player_id"),
|
||||||
"player_name": row.get("player_name"),
|
"player_name": row.get("player_name"),
|
||||||
"metric_value": _coerce_int(row.get("metric_value")),
|
"metric_value": _coerce_metric_value(row.get("metric_value")),
|
||||||
"matches_considered": _coerce_int(row.get("matches_considered")),
|
"matches_considered": matches_considered,
|
||||||
"kills": kills,
|
"kills": kills,
|
||||||
"deaths": deaths,
|
"deaths": deaths,
|
||||||
"teamkills": _coerce_int(row.get("teamkills")),
|
"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:
|
def _normalize_metric(value: str) -> LeaderboardMetric:
|
||||||
normalized = str(value or "kills").strip().lower()
|
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 normalized # type: ignore[return-value]
|
||||||
return "kills"
|
return "kills"
|
||||||
|
|
||||||
@@ -565,6 +582,10 @@ def _build_title(*, metric: str, timeframe: str, server_id: str | None) -> str:
|
|||||||
metric_label = {
|
metric_label = {
|
||||||
"kills": "Top kills",
|
"kills": "Top kills",
|
||||||
"deaths": "Top muertes",
|
"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",
|
"matches_over_100_kills": "Partidas 100+ kills",
|
||||||
"support": "Top soporte",
|
"support": "Top soporte",
|
||||||
}.get(metric, "Top kills")
|
}.get(metric, "Top kills")
|
||||||
@@ -578,6 +599,69 @@ def _coerce_int(value: object) -> int:
|
|||||||
return 0
|
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:
|
def _parse_datetime(value: object) -> datetime | None:
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
parsed = value
|
parsed = value
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ from .payloads import (
|
|||||||
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
|
||||||
|
|
||||||
|
RANKING_METRICS = {
|
||||||
|
"kills",
|
||||||
|
"deaths",
|
||||||
|
"teamkills",
|
||||||
|
"matches_considered",
|
||||||
|
"kd_ratio",
|
||||||
|
"kills_per_match",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
GET_ROUTES = {
|
GET_ROUTES = {
|
||||||
"/health": build_health_payload,
|
"/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"}:
|
if timeframe not in {"weekly", "monthly", "annual"}:
|
||||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
|
||||||
metric = params.get("metric", ["kills"])[0]
|
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")
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
|
||||||
limit = _parse_limit(parsed.query)
|
limit = _parse_limit(parsed.query)
|
||||||
if limit is None:
|
if limit is None:
|
||||||
|
|||||||
@@ -42,6 +42,56 @@ The first Ranking page supports:
|
|||||||
- filter changes without manual page reload where feasible
|
- filter changes without manual page reload where feasible
|
||||||
- clear differentiation between `ready`, `missing`, `empty`, `offline` and request-error states
|
- 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
|
## V1 Non-goals
|
||||||
|
|
||||||
- Elo/MMR
|
- Elo/MMR
|
||||||
@@ -73,6 +123,7 @@ The first Ranking page supports:
|
|||||||
- Reader: existing weekly/monthly selection logic in `select_leaderboard_window(...)`.
|
- Reader: existing weekly/monthly selection logic in `select_leaderboard_window(...)`.
|
||||||
- Ordering: metric desc, `matches_considered` desc, `player_name` asc.
|
- Ordering: metric desc, `matches_considered` desc, `player_name` asc.
|
||||||
- V1 metric commitment: `kills`.
|
- V1 metric commitment: `kills`.
|
||||||
|
- V1.1 metric support: `kills`, `deaths`, `teamkills`, `matches_considered`, `kd_ratio`, `kills_per_match`.
|
||||||
|
|
||||||
### Annual
|
### Annual
|
||||||
|
|
||||||
@@ -80,13 +131,15 @@ The first Ranking page supports:
|
|||||||
- Reader: existing annual snapshot loader.
|
- Reader: existing annual snapshot loader.
|
||||||
- No annual recomputation during public requests.
|
- No annual recomputation during public requests.
|
||||||
- Missing annual data must surface as a controlled `snapshot_status="missing"` state.
|
- 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
|
## API Contract Direction
|
||||||
|
|
||||||
### Dedicated Ranking endpoint
|
### Dedicated Ranking endpoint
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/ranking?timeframe=weekly|monthly|annual&server_id=<server-or-all>&metric=kills&limit=20&year=<year-when-annual>
|
GET /api/ranking?timeframe=weekly|monthly|annual&server_id=<server-or-all>&metric=<metric>&limit=20&year=<year-when-annual>
|
||||||
```
|
```
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
@@ -104,10 +157,13 @@ Query rules:
|
|||||||
Validation rules:
|
Validation rules:
|
||||||
|
|
||||||
- allowed `timeframe`: `weekly`, `monthly`, `annual`
|
- 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`
|
- allowed `limit`: `1..100`
|
||||||
- `year` must be a positive integer when annual is requested
|
- `year` must be a positive integer when annual is requested
|
||||||
- annual requests ignore weekly/monthly runtime window policy and read snapshots only
|
- 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
|
## Response Contract
|
||||||
|
|
||||||
@@ -118,7 +174,7 @@ Validation rules:
|
|||||||
"page_kind": "global-ranking",
|
"page_kind": "global-ranking",
|
||||||
"timeframe": "monthly",
|
"timeframe": "monthly",
|
||||||
"server_id": "all",
|
"server_id": "all",
|
||||||
"metric": "kills",
|
"metric": "kills_per_match",
|
||||||
"limit": 20,
|
"limit": 20,
|
||||||
"requested_limit": 20,
|
"requested_limit": 20,
|
||||||
"window_start": "2026-06-01T00:00:00Z",
|
"window_start": "2026-06-01T00:00:00Z",
|
||||||
@@ -137,12 +193,13 @@ Validation rules:
|
|||||||
"ranking_position": 1,
|
"ranking_position": 1,
|
||||||
"player_id": "76561198000000000",
|
"player_id": "76561198000000000",
|
||||||
"player_name": "Rambo",
|
"player_name": "Rambo",
|
||||||
"metric_value": 421,
|
"metric_value": 30.07,
|
||||||
"matches_considered": 14,
|
"matches_considered": 14,
|
||||||
"kills": 421,
|
"kills": 421,
|
||||||
"deaths": 280,
|
"deaths": 280,
|
||||||
"teamkills": 3,
|
"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`
|
- `timeframe`: `weekly`, `monthly`, `annual`
|
||||||
- `server_id`: `all` or one supported active server id
|
- `server_id`: `all` or one supported active server id
|
||||||
- `metric`: `kills` in V1
|
- `metric`: requested supported metric
|
||||||
- `limit`: effective served limit
|
- `limit`: effective served limit
|
||||||
- `requested_limit`: requested client limit
|
- `requested_limit`: requested client limit
|
||||||
- `snapshot_status`: always present for frontend state handling
|
- `snapshot_status`: always present for frontend state handling
|
||||||
@@ -172,6 +229,13 @@ Required item fields:
|
|||||||
- `deaths`
|
- `deaths`
|
||||||
- `teamkills`
|
- `teamkills`
|
||||||
- `kd_ratio`
|
- `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:
|
Weekly/monthly-only metadata:
|
||||||
|
|
||||||
@@ -207,6 +271,8 @@ Annual-only metadata:
|
|||||||
- Read only from annual snapshots.
|
- Read only from annual snapshots.
|
||||||
- `snapshot_status="ready"` with `items=[]` is a valid empty-ready state.
|
- `snapshot_status="ready"` with `items=[]` is a valid empty-ready state.
|
||||||
- `snapshot_status="missing"` is not a backend crash and must be rendered distinctly.
|
- `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
|
## 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
|
- 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
|
- annual snapshot missing explains that the annual snapshot has not been generated yet
|
||||||
- unsupported metric does not silently downgrade to another metric
|
- 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
|
- controlled error does not break page navigation or filter controls
|
||||||
|
|
||||||
## Backend Behavior Requirements
|
## Backend Behavior Requirements
|
||||||
@@ -235,6 +302,24 @@ Expected rendering behavior:
|
|||||||
- Do not expand annual generation logic inside request handling.
|
- Do not expand annual generation logic inside request handling.
|
||||||
- Keep server scope limited to active supported scopes: `all`, `comunidad-hispana-01`, `comunidad-hispana-02`.
|
- 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 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
|
## Frontend Integration Guidance
|
||||||
|
|
||||||
|
|||||||
@@ -1246,6 +1246,21 @@ h2 {
|
|||||||
margin-top: 0;
|
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 {
|
.ranking-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
@@ -1293,6 +1308,12 @@ h2 {
|
|||||||
text-transform: uppercase;
|
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 {
|
.ranking-table-shell {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -1326,6 +1347,11 @@ h2 {
|
|||||||
background: rgba(183, 201, 125, 0.05);
|
background: rgba(183, 201, 125, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ranking-table__metric {
|
||||||
|
color: var(--accent-warm);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.ranking-player {
|
.ranking-player {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@@ -13,16 +13,41 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const metaNode = document.getElementById("ranking-meta");
|
const metaNode = document.getElementById("ranking-meta");
|
||||||
const tableNode = document.getElementById("ranking-table");
|
const tableNode = document.getElementById("ranking-table");
|
||||||
const tableBodyNode = document.getElementById("ranking-table-body");
|
const tableBodyNode = document.getElementById("ranking-table-body");
|
||||||
|
const metricHeadingNode = document.getElementById("ranking-metric-heading");
|
||||||
const emptyNode = document.getElementById("ranking-empty");
|
const emptyNode = document.getElementById("ranking-empty");
|
||||||
|
const filterNoteNode = document.getElementById("ranking-filter-note");
|
||||||
|
|
||||||
const currentYear = new Date().getUTCFullYear();
|
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;
|
let isBackendOnline = false;
|
||||||
|
|
||||||
if (yearInput) {
|
if (yearInput) {
|
||||||
yearInput.value = String(currentYear);
|
yearInput.value = String(currentYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyInitialUrlState();
|
||||||
toggleYearField();
|
toggleYearField();
|
||||||
|
syncMetricState();
|
||||||
setBackendState("Comprobando disponibilidad del backend", false);
|
setBackendState("Comprobando disponibilidad del backend", false);
|
||||||
setRankingState("neutral", "Esperando filtros para cargar el ranking global.");
|
setRankingState("neutral", "Esperando filtros para cargar el ranking global.");
|
||||||
clearRankingSurface();
|
clearRankingSurface();
|
||||||
@@ -31,6 +56,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (timeframeSelect) {
|
if (timeframeSelect) {
|
||||||
timeframeSelect.addEventListener("change", () => {
|
timeframeSelect.addEventListener("change", () => {
|
||||||
toggleYearField();
|
toggleYearField();
|
||||||
|
syncMetricState();
|
||||||
|
updateUrlState();
|
||||||
void loadRanking();
|
void loadRanking();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -40,13 +67,25 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
node.addEventListener("change", () => {
|
node.addEventListener("change", () => {
|
||||||
|
syncMetricState();
|
||||||
|
updateUrlState();
|
||||||
void loadRanking();
|
void loadRanking();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (yearInput) {
|
||||||
|
yearInput.addEventListener("change", () => {
|
||||||
|
updateUrlState();
|
||||||
|
if (String(timeframeSelect?.value || defaultTimeframe) === "annual") {
|
||||||
|
void loadRanking();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (form) {
|
if (form) {
|
||||||
form.addEventListener("submit", (event) => {
|
form.addEventListener("submit", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
updateUrlState();
|
||||||
void loadRanking();
|
void loadRanking();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -69,6 +108,57 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
stateNode.className = `stats-state stats-state--${state}`;
|
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() {
|
function toggleYearField() {
|
||||||
const isAnnual = timeframeSelect?.value === "annual";
|
const isAnnual = timeframeSelect?.value === "annual";
|
||||||
if (yearWrap) {
|
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() {
|
function clearRankingSurface() {
|
||||||
if (metaNode) {
|
if (metaNode) {
|
||||||
metaNode.innerHTML = "";
|
metaNode.innerHTML = "";
|
||||||
@@ -115,7 +246,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
throw new Error("Unexpected health payload");
|
throw new Error("Unexpected health payload");
|
||||||
}
|
}
|
||||||
setBackendState("Backend operativo", true);
|
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();
|
void loadRanking();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Ranking health check failed", error);
|
console.warn("Ranking health check failed", error);
|
||||||
@@ -128,10 +264,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadRanking() {
|
async function loadRanking() {
|
||||||
const timeframe = String(timeframeSelect?.value || "weekly");
|
const timeframe = String(timeframeSelect?.value || defaultTimeframe);
|
||||||
const serverId = String(serverSelect?.value || "all");
|
const serverId = String(serverSelect?.value || defaultServerId);
|
||||||
const metric = String(metricSelect?.value || "kills");
|
const metric = String(metricSelect?.value || defaultMetric);
|
||||||
const limit = String(limitSelect?.value || "20");
|
const limit = String(limitSelect?.value || defaultLimit);
|
||||||
|
|
||||||
if (!isBackendOnline) {
|
if (!isBackendOnline) {
|
||||||
setRankingState("error", "Backend no disponible. El ranking queda en estado offline.");
|
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()}`);
|
const response = await fetch(`${backendBaseUrl}/api/ranking?${searchParams.toString()}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorPayload = await safeParseJson(response);
|
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);
|
handleRequestError(response.status, errorMessage, timeframe);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -191,10 +329,28 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
function handleRequestError(statusCode, errorMessage, timeframe) {
|
function handleRequestError(statusCode, errorMessage, timeframe) {
|
||||||
const normalizedMessage = String(errorMessage || "");
|
const normalizedMessage = String(errorMessage || "");
|
||||||
if (statusCode === 400 && normalizedMessage.includes("metric")) {
|
if (statusCode === 400 && normalizedMessage.includes("limit")) {
|
||||||
setRankingState("warning", "La metrica solicitada no esta soportada en V1.");
|
setRankingState("warning", "El limite solicitado no es valido.");
|
||||||
renderEmptyState(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -208,11 +364,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
renderEmptyState("Usa una ventana semanal, mensual o anual.");
|
renderEmptyState("Usa una ventana semanal, mensual o anual.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (timeframe === "annual") {
|
setRankingState(
|
||||||
setRankingState("error", "Error controlado al cargar el ranking anual.");
|
"error",
|
||||||
} else {
|
timeframe === "annual"
|
||||||
setRankingState("error", "Error controlado al cargar el ranking.");
|
? "Error controlado al cargar el ranking anual."
|
||||||
}
|
: "Error controlado al cargar el ranking.",
|
||||||
|
);
|
||||||
renderEmptyState(
|
renderEmptyState(
|
||||||
"La consulta devolvio un error controlado. Ajusta los filtros y vuelve a intentarlo.",
|
"La consulta devolvio un error controlado. Ajusta los filtros y vuelve a intentarlo.",
|
||||||
);
|
);
|
||||||
@@ -220,16 +377,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
function renderRanking(data) {
|
function renderRanking(data) {
|
||||||
const items = Array.isArray(data.items) ? data.items : [];
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
const timeframe = String(data.timeframe || "weekly");
|
const timeframe = String(data.timeframe || defaultTimeframe);
|
||||||
const serverId = String(data.server_id || "all");
|
const serverId = String(data.server_id || defaultServerId);
|
||||||
const metric = String(data.metric || "kills");
|
const metric = String(data.metric || defaultMetric);
|
||||||
const snapshotStatus = String(data.snapshot_status || "").toLowerCase();
|
const snapshotStatus = String(data.snapshot_status || "").toLowerCase();
|
||||||
|
|
||||||
if (titleNode) {
|
if (titleNode) {
|
||||||
titleNode.textContent =
|
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) {
|
if (metaNode) {
|
||||||
metaNode.innerHTML = buildMetaMarkup(data);
|
metaNode.innerHTML = buildMetaMarkup(data);
|
||||||
}
|
}
|
||||||
@@ -259,11 +420,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
setRankingState(
|
setRankingState(
|
||||||
"ready",
|
"ready",
|
||||||
`Ranking ${labelForTimeframe(timeframe)} listo para ${labelForServer(serverId)} por ${metric}.`,
|
`${labelForMetric(metric)} listo en ${labelForTimeframe(timeframe)} para ${labelForServer(serverId)}.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tableBodyNode) {
|
if (tableBodyNode) {
|
||||||
tableBodyNode.innerHTML = items.map(renderRow).join("");
|
tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join("");
|
||||||
}
|
}
|
||||||
if (tableNode) {
|
if (tableNode) {
|
||||||
tableNode.hidden = false;
|
tableNode.hidden = false;
|
||||||
@@ -275,23 +436,38 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
function buildMetaMarkup(data) {
|
function buildMetaMarkup(data) {
|
||||||
const source = data.source && typeof data.source === "object" ? data.source : {};
|
const source = data.source && typeof data.source === "object" ? data.source : {};
|
||||||
const parts = [
|
const timeframe = String(data.timeframe || defaultTimeframe);
|
||||||
`<article class="ranking-meta-card"><p>Servidor</p><strong>${escapeHtml(labelForServer(data.server_id))}</strong></article>`,
|
const metric = String(data.metric || defaultMetric);
|
||||||
`<article class="ranking-meta-card"><p>Ventana</p><strong>${escapeHtml(labelForWindow(data))}</strong></article>`,
|
const cards = [
|
||||||
`<article class="ranking-meta-card"><p>Fuente</p><strong>${escapeHtml(String(source.read_model || "No disponible"))}</strong></article>`,
|
{ label: "Periodo activo", value: labelForTimeframe(timeframe), active: true },
|
||||||
`<article class="ranking-meta-card"><p>Actualizado</p><strong>${escapeHtml(formatDateTime(source.generated_at))}</strong></article>`,
|
{ 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") {
|
if (timeframe === "annual") {
|
||||||
parts.push(
|
cards.push({
|
||||||
`<article class="ranking-meta-card"><p>Snapshot</p><strong>${escapeHtml(String(data.snapshot_status || "missing"))}</strong></article>`,
|
label: "Snapshot",
|
||||||
);
|
value: String(data.snapshot_status || "missing"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join("");
|
return cards
|
||||||
|
.map(
|
||||||
|
(card) => `
|
||||||
|
<article class="ranking-meta-card${card.active ? " ranking-meta-card--active" : ""}">
|
||||||
|
<p>${escapeHtml(card.label)}</p>
|
||||||
|
<strong>${escapeHtml(String(card.value || "No disponible"))}</strong>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRow(item) {
|
function renderRow(item, metric) {
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>#${safeInt(item.ranking_position, 0)}</td>
|
<td>#${safeInt(item.ranking_position, 0)}</td>
|
||||||
@@ -301,11 +477,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
<span>${escapeHtml(String(item.player_id || "Sin ID"))}</span>
|
<span>${escapeHtml(String(item.player_id || "Sin ID"))}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>${safeInt(item.metric_value, 0)}</td>
|
<td class="ranking-table__metric">${formatMetricValue(item.metric_value, metric)}</td>
|
||||||
<td>${safeInt(item.matches_considered, 0)}</td>
|
<td>${safeInt(item.kills, 0)}</td>
|
||||||
<td>${safeInt(item.deaths, 0)}</td>
|
<td>${safeInt(item.deaths, 0)}</td>
|
||||||
<td>${safeInt(item.teamkills, 0)}</td>
|
<td>${safeInt(item.teamkills, 0)}</td>
|
||||||
|
<td>${safeInt(item.matches_considered, 0)}</td>
|
||||||
<td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td>
|
<td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td>
|
||||||
|
<td>${safeDecimal(item.kills_per_match, 2, "0.00")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -337,12 +515,31 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return "semanal";
|
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) {
|
function safeInt(value, fallback) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isFinite(parsed)) {
|
if (!Number.isFinite(parsed)) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
return parsed;
|
return Math.trunc(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeDecimal(value, maximumFractionDigits, fallback) {
|
function safeDecimal(value, maximumFractionDigits, fallback) {
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
<span class="hero__title-accent">Global</span>
|
<span class="hero__title-accent">Global</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="hero__text">
|
<p class="hero__text">
|
||||||
Consulta los lideres publicos por kills con lectura RCON-first, cambia
|
Consulta los lideres publicos por metrica con lectura RCON-first,
|
||||||
periodo y servidor sin salir de la pagina, y usa Stats solo cuando
|
cambia periodo y servidor sin salir de la pagina, y deja Stats para
|
||||||
necesites revisar el rendimiento de un jugador concreto.
|
revisar el rendimiento de un jugador concreto.
|
||||||
</p>
|
</p>
|
||||||
<p class="status-chip status-chip--fallback" id="ranking-backend-state">
|
<p class="status-chip status-chip--fallback" id="ranking-backend-state">
|
||||||
Comprobando disponibilidad del backend
|
Comprobando disponibilidad del backend
|
||||||
@@ -76,15 +76,22 @@
|
|||||||
<label class="stats-search-form__label" for="ranking-metric">
|
<label class="stats-search-form__label" for="ranking-metric">
|
||||||
Metrica
|
Metrica
|
||||||
<select class="ranking-select" id="ranking-metric" name="metric">
|
<select class="ranking-select" id="ranking-metric" name="metric">
|
||||||
<option value="kills">Kills</option>
|
<option value="kills">Kills totales</option>
|
||||||
|
<option value="deaths">Deaths</option>
|
||||||
|
<option value="teamkills">Teamkills</option>
|
||||||
|
<option value="matches_considered">Partidas jugadas</option>
|
||||||
|
<option value="kd_ratio">K/D</option>
|
||||||
|
<option value="kills_per_match">Kills por partida</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="stats-search-form__label" for="ranking-limit">
|
<label class="stats-search-form__label" for="ranking-limit">
|
||||||
Limite
|
Limite
|
||||||
<select class="ranking-select" id="ranking-limit" name="limit">
|
<select class="ranking-select" id="ranking-limit" name="limit">
|
||||||
|
<option value="5">Top 5</option>
|
||||||
<option value="10">Top 10</option>
|
<option value="10">Top 10</option>
|
||||||
<option value="20" selected>Top 20</option>
|
<option value="20" selected>Top 20</option>
|
||||||
<option value="30">Top 30</option>
|
<option value="50">Top 50</option>
|
||||||
|
<option value="100">Top 100</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="stats-search-form__label" for="ranking-year" id="ranking-year-wrap" hidden>
|
<label class="stats-search-form__label" for="ranking-year" id="ranking-year-wrap" hidden>
|
||||||
@@ -101,6 +108,9 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="ranking-form__note" id="ranking-filter-note">
|
||||||
|
Ranking compara top globales. Para buscar un jugador concreto usa Stats.
|
||||||
|
</p>
|
||||||
<div class="hero__actions ranking-form__actions">
|
<div class="hero__actions ranking-form__actions">
|
||||||
<button class="discord-button" type="submit">Actualizar ranking</button>
|
<button class="discord-button" type="submit">Actualizar ranking</button>
|
||||||
<a class="secondary-button secondary-button--compact" href="./stats.html">
|
<a class="secondary-button secondary-button--compact" href="./stats.html">
|
||||||
@@ -127,11 +137,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Pos.</th>
|
<th>Pos.</th>
|
||||||
<th>Jugador</th>
|
<th>Jugador</th>
|
||||||
|
<th id="ranking-metric-heading">Valor activo</th>
|
||||||
<th>Kills</th>
|
<th>Kills</th>
|
||||||
<th>Partidas</th>
|
|
||||||
<th>Deaths</th>
|
<th>Deaths</th>
|
||||||
<th>Teamkills</th>
|
<th>Teamkills</th>
|
||||||
|
<th>Partidas</th>
|
||||||
<th>K/D</th>
|
<th>K/D</th>
|
||||||
|
<th>Kills/partida</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="ranking-table-body"></tbody>
|
<tbody id="ranking-table-body"></tbody>
|
||||||
|
|||||||
@@ -32,6 +32,9 @@
|
|||||||
<a class="secondary-button secondary-button--ghost" href="./index.html">
|
<a class="secondary-button secondary-button--ghost" href="./index.html">
|
||||||
Volver al inicio
|
Volver al inicio
|
||||||
</a>
|
</a>
|
||||||
|
<a class="secondary-button secondary-button--compact" href="./ranking.html">
|
||||||
|
Ranking
|
||||||
|
</a>
|
||||||
<a class="secondary-button secondary-button--compact" href="./historico.html">
|
<a class="secondary-button secondary-button--compact" href="./historico.html">
|
||||||
Historico
|
Historico
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -49,9 +49,13 @@ function Assert-LastExitCode {
|
|||||||
|
|
||||||
Assert-FileExists "frontend/stats.html" "Missing frontend/stats.html"
|
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/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"
|
$statsHtml = Get-Content -Raw "frontend/stats.html"
|
||||||
$statsJs = Get-Content -Raw "frontend/assets/js/stats.js"
|
$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"' `
|
Assert-ContainsText $statsHtml 'id="stats-search-form"' `
|
||||||
"Stats page no longer exposes the player 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."
|
"Stats page no longer exposes annual ranking state node."
|
||||||
Assert-ContainsText $statsHtml 'id="stats-backend-state"' `
|
Assert-ContainsText $statsHtml 'id="stats-backend-state"' `
|
||||||
"Stats page no longer exposes backend state chip."
|
"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")' `
|
Assert-ContainsText $statsJs 'getElementById("stats-search-form")' `
|
||||||
"Stats JS no longer sets up search form lookup."
|
"Stats JS no longer sets up search form lookup."
|
||||||
Assert-ContainsText $statsJs "loadPlayerProfile(" `
|
Assert-ContainsText $statsJs "loadPlayerProfile(" `
|
||||||
"Stats JS no longer defines 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" `
|
Assert-ContainsText $statsJs "/api/stats/players/search" `
|
||||||
"Stats frontend no longer targets the player search endpoint."
|
"Stats frontend no longer targets the player search endpoint."
|
||||||
Assert-ContainsText $statsJs "/api/stats/rankings/annual" `
|
Assert-ContainsText $statsJs "/api/stats/rankings/annual" `
|
||||||
@@ -114,6 +141,10 @@ def require_str(value, message):
|
|||||||
require(isinstance(value, str), message)
|
require(isinstance(value, str), message)
|
||||||
|
|
||||||
|
|
||||||
|
def require_number(value, message):
|
||||||
|
require(isinstance(value, (int, float)), message)
|
||||||
|
|
||||||
|
|
||||||
health_status, health_payload = read_payload("/health")
|
health_status, health_payload = read_payload("/health")
|
||||||
require(health_status == 200, "Route resolver /health should return 200.")
|
require(health_status == 200, "Route resolver /health should return 200.")
|
||||||
require(health_payload.get("status") == "ok", "/health payload should be ok.")
|
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("items"), list), "Global ranking weekly items must be list.")
|
||||||
require(isinstance(weekly_ranking_data.get("source"), dict), "Global ranking weekly should expose source metadata.")
|
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(
|
monthly_ranking_status, monthly_ranking_payload = read_payload(
|
||||||
"/api/ranking?timeframe=monthly&server_id=comunidad-hispana-01&metric=kills&limit=20"
|
"/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("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.")
|
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(
|
annual_ranking_status, annual_ranking_payload = read_payload(
|
||||||
f"/api/ranking?timeframe=annual&year={current_year}&server_id=all&metric=kills&limit=20"
|
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(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.")
|
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(
|
low_limit_ranking_status, low_limit_ranking_payload = read_payload(
|
||||||
"/api/ranking?timeframe=weekly&server_id=all&metric=kills&limit=3"
|
"/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.")
|
require(high_limit_ranking_status == 400, "Global ranking with limit=101 should return 400.")
|
||||||
|
|
||||||
unsupported_metric_ranking_status, _ = read_payload(
|
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.")
|
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(
|
unsupported_timeframe_ranking_status, _ = read_payload(
|
||||||
"/api/ranking?timeframe=seasonal&server_id=all&metric=kills&limit=20"
|
"/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."
|
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
|
$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") {
|
if ($rankingAnnualPayload.status -ne "ok") {
|
||||||
throw "Live global ranking annual route should return status=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."
|
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) {
|
if ($rankingUnsupportedMetricStatus -ne 400) {
|
||||||
throw "Live global ranking with unsupported metric should return HTTP 400."
|
throw "Live global ranking with unsupported metric should return HTTP 400."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user