Polish player profile KPM links and labels
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# TASK-253 - Polish player profile KPM, links and labels
|
||||
|
||||
## Summary
|
||||
|
||||
This task improves the public player profile in `stats.html` without changing the validated historical match-detail KPM model.
|
||||
|
||||
Changes included:
|
||||
|
||||
- real weekly/monthly profile KPM when safe
|
||||
- no visible raw player ID in the personal profile card or profile heading
|
||||
- translated `current-week` / `current-month` labels
|
||||
- external profile buttons aligned with `historico-partida`
|
||||
|
||||
Teamkills were not changed here. TASK-251 remains the reference audit for that problem.
|
||||
|
||||
## Files Read First
|
||||
|
||||
- `frontend/stats.html`
|
||||
- `frontend/assets/js/stats.js`
|
||||
- `frontend/assets/js/historico-partida.js`
|
||||
- `backend/app/payloads.py`
|
||||
- `backend/app/rcon_historical_player_stats.py`
|
||||
- `docs/HISTORICAL_MATCH_KILLS_PER_MINUTE_ANALYSIS.md`
|
||||
|
||||
## Real KPM Decision
|
||||
|
||||
The player profile does not use match duration and does not reuse `kills_per_match`.
|
||||
|
||||
It now exposes real KPM only when the selected weekly or monthly window can aggregate player facts with:
|
||||
|
||||
- `active_time_source = connection_intervals`
|
||||
- `active_time_source = connection_intervals_carryover`
|
||||
- `player_active_seconds >= HLL_KPM_MIN_ACTIVE_SECONDS`
|
||||
|
||||
Formula:
|
||||
|
||||
```text
|
||||
sum(eligible_kills) / (sum(eligible_player_active_seconds) / 60)
|
||||
```
|
||||
|
||||
If the profile window has:
|
||||
|
||||
- no active time -> `kpm_status = missing_active_time`
|
||||
- only fallback spans without reliable connection intervals -> `kpm_status = missing_connection_intervals`
|
||||
- real connection rows but below threshold -> `kpm_status = insufficient_active_time`
|
||||
|
||||
Only `kpm_status = ready` is rendered in the frontend.
|
||||
|
||||
## Backend Notes
|
||||
|
||||
`build_stats_player_profile_payload(...)` now exposes:
|
||||
|
||||
- `platform`
|
||||
- `steam_id_64`
|
||||
- `epic_id`
|
||||
- `external_profile_links`
|
||||
- `player_active_seconds`
|
||||
- `player_active_minutes`
|
||||
- `kpm`
|
||||
- `kpm_status`
|
||||
- `active_time_source`
|
||||
- `active_time_coverage`
|
||||
|
||||
If `player_period_stats` is unavailable for the requested weekly/monthly profile, the endpoint now falls back to runtime aggregation for that single player and window instead of returning an empty read-model-only payload.
|
||||
|
||||
This keeps the route useful without touching scheduler or snapshot policies.
|
||||
|
||||
## Frontend Notes
|
||||
|
||||
Profile polish in `stats.js`:
|
||||
|
||||
- removed visible `ID:` from the identity card
|
||||
- removed raw player ID from the visible profile heading/state
|
||||
- translated:
|
||||
- `current-week` -> `Semana actual`
|
||||
- `current-month` -> `Mes actual`
|
||||
- fallback labels for previous windows
|
||||
- added external profile buttons using the same destinations as `historico-partida`
|
||||
- keeps defensive image `onerror`
|
||||
- renders KPM only when `kpm_status == ready`
|
||||
|
||||
Profile buttons by platform:
|
||||
|
||||
- Steam:
|
||||
- Steam
|
||||
- Hellor
|
||||
- HLL Records
|
||||
- Helo
|
||||
- Epic:
|
||||
- Hellor
|
||||
- HLL Records
|
||||
|
||||
## Teamkills Note
|
||||
|
||||
No teamkills fix was implemented here.
|
||||
|
||||
Reference:
|
||||
|
||||
- `TASK-251-investigate-teamkills-ranking-zeroes.md`
|
||||
|
||||
Observed state remains:
|
||||
|
||||
- `rcon_match_player_stats.teamkills` stays at zero in the audited dataset
|
||||
- same-team kill events were not present in the audited AdminLog dataset
|
||||
- lifetime profile snapshots can show TK counters, but they are not a valid direct source for annual ranking without redesign/backfill
|
||||
|
||||
## Validation
|
||||
|
||||
Executed:
|
||||
|
||||
- `node --check frontend/assets/js/stats.js`
|
||||
- `python -m compileall backend/app`
|
||||
- `cd backend; python -m unittest tests.test_current_match_payload`
|
||||
- `cd backend; python -m unittest tests.test_historical_snapshot_refresh`
|
||||
- `cd backend; python -m unittest tests.test_stats_player_profile_payload`
|
||||
|
||||
Static checks:
|
||||
|
||||
- no visible `ID:` remains in the personal profile card
|
||||
- `current-week` / `current-month` no longer remain as visible UI labels
|
||||
- external profile buttons do not print raw IDs on screen
|
||||
- KPM is rendered only for `kpm_status == ready`
|
||||
|
||||
## Notes
|
||||
|
||||
- No scheduler changes.
|
||||
- No RCON or server configuration changes.
|
||||
- No asset changes were required.
|
||||
@@ -785,6 +785,10 @@ def build_stats_player_profile_payload(
|
||||
"data": {
|
||||
"player_id": result.get("player_id"),
|
||||
"player_name": result.get("player_name"),
|
||||
"platform": result.get("platform"),
|
||||
"steam_id_64": result.get("steam_id_64"),
|
||||
"epic_id": result.get("epic_id"),
|
||||
"external_profile_links": result.get("external_profile_links") or {},
|
||||
"server_id": result.get("server_id"),
|
||||
"timeframe": result.get("timeframe"),
|
||||
"window_start": _to_iso_or_none(result.get("window_start")),
|
||||
@@ -797,6 +801,12 @@ def build_stats_player_profile_payload(
|
||||
"kd_ratio": kd_ratio,
|
||||
"kills_per_match": kills_per_match,
|
||||
"deaths_per_match": deaths_per_match,
|
||||
"player_active_seconds": result.get("player_active_seconds"),
|
||||
"player_active_minutes": result.get("player_active_minutes"),
|
||||
"kpm": result.get("kpm"),
|
||||
"kpm_status": result.get("kpm_status"),
|
||||
"active_time_source": result.get("active_time_source"),
|
||||
"active_time_coverage": result.get("active_time_coverage"),
|
||||
"weekly_ranking": result.get("weekly_ranking"),
|
||||
"monthly_ranking": result.get("monthly_ranking"),
|
||||
"source": result.get("source"),
|
||||
|
||||
@@ -9,13 +9,20 @@ from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
import unicodedata
|
||||
|
||||
from .config import get_kpm_min_active_seconds
|
||||
from .config import get_storage_path, use_postgres_rcon_storage
|
||||
from .historical_storage import ALL_SERVERS_SLUG
|
||||
from .player_external_profiles import build_external_player_profile_fields
|
||||
from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage
|
||||
from .rcon_historical_leaderboards import select_leaderboard_window
|
||||
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
|
||||
from .rcon_historical_leaderboards import _to_iso
|
||||
|
||||
REAL_KPM_ACTIVE_TIME_SOURCES = (
|
||||
"connection_intervals",
|
||||
"connection_intervals_carryover",
|
||||
)
|
||||
|
||||
PLAYER_SEARCH_INDEX_SERVER_KEYS = (
|
||||
ALL_SERVERS_SLUG,
|
||||
"comunidad-hispana-01",
|
||||
@@ -371,12 +378,18 @@ def get_rcon_materialized_player_stats(
|
||||
if read_model_result is not None:
|
||||
return read_model_result
|
||||
|
||||
return _build_missing_player_period_stats_result(
|
||||
runtime_result = _get_rcon_materialized_player_stats_runtime(
|
||||
player_id=normalized_player_id,
|
||||
server_id=server_id,
|
||||
timeframe=resolved_timeframe,
|
||||
missing_reason=fallback_reason or "player-period-stats-unavailable",
|
||||
db_path=db_path,
|
||||
)
|
||||
runtime_result.setdefault("source", {})
|
||||
runtime_result["source"]["fallback_used"] = True
|
||||
runtime_result["source"]["fallback_reason"] = fallback_reason or "player-period-stats-unavailable"
|
||||
runtime_result["source"]["read_model"] = "player-period-stats"
|
||||
runtime_result["source"]["freshness"] = "runtime-fallback"
|
||||
return runtime_result
|
||||
|
||||
return _get_rcon_materialized_player_stats_runtime(
|
||||
player_id=normalized_player_id,
|
||||
@@ -415,6 +428,13 @@ def _get_rcon_materialized_player_stats_runtime(
|
||||
server_id=resolved_server_id,
|
||||
window=selected_window if resolved_timeframe != "all" else None,
|
||||
)
|
||||
active_time = _fetch_player_active_time_summary(
|
||||
connection=connection,
|
||||
player_id=normalized_player_id,
|
||||
server_id=resolved_server_id,
|
||||
window=selected_window if resolved_timeframe != "all" else None,
|
||||
total_matches_considered=int(player_stats.get("matches_considered", 0) or 0),
|
||||
)
|
||||
|
||||
source_range = _fetch_source_range(
|
||||
connection=connection,
|
||||
@@ -460,6 +480,8 @@ def _get_rcon_materialized_player_stats_runtime(
|
||||
"kills": player_stats.get("kills", 0),
|
||||
"deaths": player_stats.get("deaths", 0),
|
||||
"teamkills": player_stats.get("teamkills", 0),
|
||||
**active_time,
|
||||
**build_external_player_profile_fields(player_id=normalized_player_id),
|
||||
"weekly_ranking": weekly_ranking,
|
||||
"monthly_ranking": monthly_ranking,
|
||||
"source": {
|
||||
@@ -528,6 +550,17 @@ def _get_player_period_stats_read_model(
|
||||
selected_row = rows_by_period[timeframe]
|
||||
weekly_row = rows_by_period["weekly"]
|
||||
monthly_row = rows_by_period["monthly"]
|
||||
with _connect_read_scope(resolved_path, db_path=db_path) as connection:
|
||||
active_time = _fetch_player_active_time_summary(
|
||||
connection=connection,
|
||||
player_id=player_id,
|
||||
server_id=resolved_server_id,
|
||||
window={
|
||||
"start": _to_datetime_or_none(selected_row.get("period_start")),
|
||||
"end": _to_datetime_or_none(selected_row.get("period_end")),
|
||||
},
|
||||
total_matches_considered=int(selected_row.get("matches_considered") or 0),
|
||||
)
|
||||
return (
|
||||
{
|
||||
"player_id": player_id,
|
||||
@@ -541,6 +574,8 @@ def _get_player_period_stats_read_model(
|
||||
"kills": int(selected_row.get("kills") or 0),
|
||||
"deaths": int(selected_row.get("deaths") or 0),
|
||||
"teamkills": int(selected_row.get("teamkills") or 0),
|
||||
**active_time,
|
||||
**build_external_player_profile_fields(player_id=player_id),
|
||||
"weekly_ranking": _build_player_period_ranking_payload(weekly_row),
|
||||
"monthly_ranking": _build_player_period_ranking_payload(monthly_row),
|
||||
"source": {
|
||||
@@ -825,6 +860,196 @@ def _fetch_player_stats(
|
||||
}
|
||||
|
||||
|
||||
def _fetch_player_active_time_summary(
|
||||
*,
|
||||
connection: object,
|
||||
player_id: str,
|
||||
server_id: str | None,
|
||||
window: dict[str, object] | None,
|
||||
total_matches_considered: int,
|
||||
) -> dict[str, object]:
|
||||
scope_sql, scope_params = _build_scope_sql(server_id)
|
||||
min_active_seconds = get_kpm_min_active_seconds()
|
||||
base_where = [
|
||||
"matches.source_basis = ?",
|
||||
"stats.player_id = ?",
|
||||
]
|
||||
params: list[object] = [MATCH_RESULT_SOURCE, player_id]
|
||||
if window is not None:
|
||||
base_where.append(
|
||||
"COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?"
|
||||
)
|
||||
base_where.append(
|
||||
"COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) <= ?"
|
||||
)
|
||||
params.extend([_to_iso(window["start"]), _to_iso(window["end"])])
|
||||
where_sql = " AND ".join(base_where)
|
||||
eligible_sources_placeholders = ", ".join(["?"] * len(REAL_KPM_ACTIVE_TIME_SOURCES))
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN stats.player_active_seconds IS NOT NULL
|
||||
THEN stats.match_key
|
||||
ELSE NULL
|
||||
END) AS observed_matches,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN stats.active_time_source IN ({eligible_sources_placeholders})
|
||||
THEN stats.match_key
|
||||
ELSE NULL
|
||||
END) AS real_source_matches,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN stats.active_time_source IN ({eligible_sources_placeholders})
|
||||
AND COALESCE(stats.player_active_seconds, 0) >= ?
|
||||
THEN stats.match_key
|
||||
ELSE NULL
|
||||
END) AS eligible_matches,
|
||||
SUM(CASE
|
||||
WHEN stats.active_time_source IN ({eligible_sources_placeholders})
|
||||
AND COALESCE(stats.player_active_seconds, 0) >= ?
|
||||
THEN COALESCE(stats.player_active_seconds, 0)
|
||||
ELSE 0
|
||||
END) AS player_active_seconds,
|
||||
SUM(CASE
|
||||
WHEN stats.active_time_source IN ({eligible_sources_placeholders})
|
||||
AND COALESCE(stats.player_active_seconds, 0) >= ?
|
||||
THEN COALESCE(stats.kills, 0)
|
||||
ELSE 0
|
||||
END) AS eligible_kills,
|
||||
GROUP_CONCAT(DISTINCT CASE
|
||||
WHEN stats.active_time_source IN ({eligible_sources_placeholders})
|
||||
THEN stats.active_time_source
|
||||
ELSE NULL
|
||||
END) AS eligible_sources,
|
||||
GROUP_CONCAT(DISTINCT COALESCE(stats.active_time_source, 'unavailable')) AS observed_sources
|
||||
FROM rcon_match_player_stats AS stats
|
||||
INNER JOIN rcon_materialized_matches AS matches
|
||||
ON matches.target_key = stats.target_key
|
||||
AND matches.match_key = stats.match_key
|
||||
WHERE {where_sql} {scope_sql}
|
||||
""",
|
||||
[
|
||||
*REAL_KPM_ACTIVE_TIME_SOURCES,
|
||||
*REAL_KPM_ACTIVE_TIME_SOURCES,
|
||||
min_active_seconds,
|
||||
*REAL_KPM_ACTIVE_TIME_SOURCES,
|
||||
min_active_seconds,
|
||||
*REAL_KPM_ACTIVE_TIME_SOURCES,
|
||||
min_active_seconds,
|
||||
*REAL_KPM_ACTIVE_TIME_SOURCES,
|
||||
*params,
|
||||
*scope_params,
|
||||
],
|
||||
).fetchone()
|
||||
return _build_profile_active_time_payload(
|
||||
row=dict(row) if row is not None else {},
|
||||
total_matches_considered=total_matches_considered,
|
||||
min_active_seconds=min_active_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _build_profile_active_time_payload(
|
||||
*,
|
||||
row: dict[str, object],
|
||||
total_matches_considered: int,
|
||||
min_active_seconds: int,
|
||||
) -> dict[str, object]:
|
||||
observed_matches = int(row.get("observed_matches") or 0)
|
||||
real_source_matches = int(row.get("real_source_matches") or 0)
|
||||
eligible_matches = int(row.get("eligible_matches") or 0)
|
||||
player_active_seconds = int(row.get("player_active_seconds") or 0)
|
||||
eligible_kills = int(row.get("eligible_kills") or 0)
|
||||
eligible_sources = _split_sources(row.get("eligible_sources"))
|
||||
observed_sources = _split_sources(row.get("observed_sources"))
|
||||
|
||||
if eligible_matches > 0 and player_active_seconds >= min_active_seconds:
|
||||
player_active_minutes = round(player_active_seconds / 60, 3)
|
||||
return {
|
||||
"player_active_seconds": player_active_seconds,
|
||||
"player_active_minutes": player_active_minutes,
|
||||
"kpm": round(eligible_kills / (player_active_seconds / 60), 2),
|
||||
"kpm_status": "ready",
|
||||
"active_time_source": _summarize_real_active_time_sources(eligible_sources),
|
||||
"active_time_coverage": {
|
||||
"eligible_matches": eligible_matches,
|
||||
"real_source_matches": real_source_matches,
|
||||
"observed_matches": observed_matches,
|
||||
"total_matches_considered": total_matches_considered,
|
||||
"eligible_kills": eligible_kills,
|
||||
"minimum_active_seconds": min_active_seconds,
|
||||
"sources": eligible_sources,
|
||||
},
|
||||
}
|
||||
|
||||
if real_source_matches > 0:
|
||||
return {
|
||||
"player_active_seconds": None,
|
||||
"player_active_minutes": None,
|
||||
"kpm": None,
|
||||
"kpm_status": "insufficient_active_time",
|
||||
"active_time_source": _summarize_real_active_time_sources(eligible_sources or observed_sources),
|
||||
"active_time_coverage": {
|
||||
"eligible_matches": eligible_matches,
|
||||
"real_source_matches": real_source_matches,
|
||||
"observed_matches": observed_matches,
|
||||
"total_matches_considered": total_matches_considered,
|
||||
"eligible_kills": eligible_kills,
|
||||
"minimum_active_seconds": min_active_seconds,
|
||||
"sources": observed_sources,
|
||||
},
|
||||
}
|
||||
|
||||
if observed_matches > 0 or "event_span_fallback" in observed_sources:
|
||||
return {
|
||||
"player_active_seconds": None,
|
||||
"player_active_minutes": None,
|
||||
"kpm": None,
|
||||
"kpm_status": "missing_connection_intervals",
|
||||
"active_time_source": "event_span_fallback" if observed_sources else "unavailable",
|
||||
"active_time_coverage": {
|
||||
"eligible_matches": 0,
|
||||
"real_source_matches": real_source_matches,
|
||||
"observed_matches": observed_matches,
|
||||
"total_matches_considered": total_matches_considered,
|
||||
"eligible_kills": 0,
|
||||
"minimum_active_seconds": min_active_seconds,
|
||||
"sources": observed_sources,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"player_active_seconds": None,
|
||||
"player_active_minutes": None,
|
||||
"kpm": None,
|
||||
"kpm_status": "missing_active_time",
|
||||
"active_time_source": "unavailable",
|
||||
"active_time_coverage": {
|
||||
"eligible_matches": 0,
|
||||
"real_source_matches": 0,
|
||||
"observed_matches": 0,
|
||||
"total_matches_considered": total_matches_considered,
|
||||
"eligible_kills": 0,
|
||||
"minimum_active_seconds": min_active_seconds,
|
||||
"sources": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _split_sources(value: object) -> list[str]:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return []
|
||||
return [item for item in {part.strip() for part in value.split(",")} if item]
|
||||
|
||||
|
||||
def _summarize_real_active_time_sources(sources: list[str]) -> str:
|
||||
normalized = [source for source in sources if source in REAL_KPM_ACTIVE_TIME_SOURCES]
|
||||
if not normalized:
|
||||
return "unavailable"
|
||||
if len(normalized) == 1:
|
||||
return normalized[0]
|
||||
return "connection_intervals_mixed"
|
||||
|
||||
|
||||
def _fetch_player_ranking(
|
||||
*,
|
||||
connection: object,
|
||||
|
||||
175
backend/tests/test_stats_player_profile_payload.py
Normal file
175
backend/tests/test_stats_player_profile_payload.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.payloads import build_stats_player_profile_payload
|
||||
from app.rcon_historical_player_stats import (
|
||||
_build_profile_active_time_payload,
|
||||
get_rcon_materialized_player_stats,
|
||||
)
|
||||
|
||||
|
||||
class StatsPlayerProfilePayloadTests(unittest.TestCase):
|
||||
def test_player_stats_falls_back_to_runtime_when_period_read_model_is_empty(self) -> None:
|
||||
runtime_payload = {
|
||||
"player_id": "76561198000000000",
|
||||
"player_name": "Runtime Player",
|
||||
"server_id": "all-servers",
|
||||
"timeframe": "weekly",
|
||||
"window_kind": "current-week",
|
||||
"matches_considered": 3,
|
||||
"kills": 25,
|
||||
"deaths": 12,
|
||||
"teamkills": 1,
|
||||
"weekly_ranking": {"metric": "kills", "ranking_position": 5},
|
||||
"monthly_ranking": {"metric": "kills", "ranking_position": 8},
|
||||
"source": {"primary_source": "rcon"},
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"app.rcon_historical_player_stats._get_player_period_stats_read_model",
|
||||
return_value=(None, "player-period-stats-empty"),
|
||||
),
|
||||
patch(
|
||||
"app.rcon_historical_player_stats._get_rcon_materialized_player_stats_runtime",
|
||||
return_value=runtime_payload,
|
||||
) as runtime_loader,
|
||||
):
|
||||
result = get_rcon_materialized_player_stats(
|
||||
player_id="76561198000000000",
|
||||
timeframe="weekly",
|
||||
)
|
||||
|
||||
runtime_loader.assert_called_once()
|
||||
self.assertTrue(result["source"]["fallback_used"])
|
||||
self.assertEqual(result["source"]["fallback_reason"], "player-period-stats-empty")
|
||||
self.assertEqual(result["source"]["freshness"], "runtime-fallback")
|
||||
|
||||
def test_build_stats_player_profile_payload_exposes_ready_real_kpm_and_external_links(self) -> None:
|
||||
with patch(
|
||||
"app.payloads.get_rcon_materialized_player_stats",
|
||||
return_value={
|
||||
"player_id": "76561198000000000",
|
||||
"player_name": "Steam Player",
|
||||
"server_id": "all-servers",
|
||||
"timeframe": "weekly",
|
||||
"window_start": None,
|
||||
"window_end": None,
|
||||
"window_kind": "current-week",
|
||||
"matches_considered": 4,
|
||||
"kills": 20,
|
||||
"deaths": 10,
|
||||
"teamkills": 0,
|
||||
"player_active_seconds": 1200,
|
||||
"player_active_minutes": 20.0,
|
||||
"kpm": 1.0,
|
||||
"kpm_status": "ready",
|
||||
"active_time_source": "connection_intervals",
|
||||
"active_time_coverage": {
|
||||
"eligible_matches": 4,
|
||||
"real_source_matches": 4,
|
||||
"observed_matches": 4,
|
||||
"total_matches_considered": 4,
|
||||
"eligible_kills": 20,
|
||||
"minimum_active_seconds": 60,
|
||||
"sources": ["connection_intervals"],
|
||||
},
|
||||
"platform": "steam",
|
||||
"steam_id_64": "76561198000000000",
|
||||
"external_profile_links": {
|
||||
"steam": "https://steamcommunity.com/profiles/76561198000000000",
|
||||
"hellor": "https://hellor.pro/player/76561198000000000",
|
||||
"hll_records": "https://hllrecords.com/profiles/76561198000000000",
|
||||
"helo": "https://helo-system.de/statistics/players/76561198000000000?series=2024",
|
||||
},
|
||||
"weekly_ranking": {"metric": "kills", "ranking_position": 4},
|
||||
"monthly_ranking": {"metric": "kills", "ranking_position": 7},
|
||||
"source": {"primary_source": "rcon"},
|
||||
},
|
||||
):
|
||||
payload = build_stats_player_profile_payload(
|
||||
player_id="76561198000000000",
|
||||
timeframe="weekly",
|
||||
)
|
||||
|
||||
data = payload["data"]
|
||||
self.assertEqual(data["kpm"], 1.0)
|
||||
self.assertEqual(data["kpm_status"], "ready")
|
||||
self.assertEqual(data["active_time_source"], "connection_intervals")
|
||||
self.assertEqual(data["steam_id_64"], "76561198000000000")
|
||||
self.assertEqual(data["platform"], "steam")
|
||||
self.assertIn("steam", data["external_profile_links"])
|
||||
|
||||
def test_build_stats_player_profile_payload_keeps_kpm_null_when_connection_intervals_are_missing(self) -> None:
|
||||
with patch(
|
||||
"app.payloads.get_rcon_materialized_player_stats",
|
||||
return_value={
|
||||
"player_id": "epic-player-id",
|
||||
"player_name": "Epic Player",
|
||||
"server_id": "all-servers",
|
||||
"timeframe": "monthly",
|
||||
"window_start": None,
|
||||
"window_end": None,
|
||||
"window_kind": "current-month",
|
||||
"matches_considered": 2,
|
||||
"kills": 7,
|
||||
"deaths": 4,
|
||||
"teamkills": 0,
|
||||
"player_active_seconds": None,
|
||||
"player_active_minutes": None,
|
||||
"kpm": None,
|
||||
"kpm_status": "missing_connection_intervals",
|
||||
"active_time_source": "event_span_fallback",
|
||||
"active_time_coverage": {
|
||||
"eligible_matches": 0,
|
||||
"real_source_matches": 0,
|
||||
"observed_matches": 2,
|
||||
"total_matches_considered": 2,
|
||||
"eligible_kills": 0,
|
||||
"minimum_active_seconds": 60,
|
||||
"sources": ["event_span_fallback"],
|
||||
},
|
||||
"platform": "epic",
|
||||
"epic_id": "0123456789abcdef0123456789abcdef",
|
||||
"external_profile_links": {
|
||||
"hellor": "https://hellor.pro/player/0123456789abcdef0123456789abcdef",
|
||||
"hll_records": "https://hllrecords.com/profiles/0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
"weekly_ranking": {"metric": "kills", "ranking_position": None},
|
||||
"monthly_ranking": {"metric": "kills", "ranking_position": None},
|
||||
"source": {"primary_source": "rcon"},
|
||||
},
|
||||
):
|
||||
payload = build_stats_player_profile_payload(
|
||||
player_id="0123456789abcdef0123456789abcdef",
|
||||
timeframe="monthly",
|
||||
)
|
||||
|
||||
data = payload["data"]
|
||||
self.assertIsNone(data["kpm"])
|
||||
self.assertEqual(data["kpm_status"], "missing_connection_intervals")
|
||||
self.assertNotIn("steam", data["external_profile_links"])
|
||||
|
||||
def test_profile_active_time_payload_marks_ready_only_for_real_connection_intervals(self) -> None:
|
||||
payload = _build_profile_active_time_payload(
|
||||
row={
|
||||
"observed_matches": 3,
|
||||
"real_source_matches": 3,
|
||||
"eligible_matches": 3,
|
||||
"player_active_seconds": 600,
|
||||
"eligible_kills": 10,
|
||||
"eligible_sources": "connection_intervals,connection_intervals_carryover",
|
||||
"observed_sources": "connection_intervals,connection_intervals_carryover",
|
||||
},
|
||||
total_matches_considered=3,
|
||||
min_active_seconds=60,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["kpm"], 1.0)
|
||||
self.assertEqual(payload["kpm_status"], "ready")
|
||||
self.assertEqual(payload["active_time_source"], "connection_intervals_mixed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -174,6 +174,38 @@ This avoids false `0.00` values for:
|
||||
- rows below `HLL_KPM_MIN_ACTIVE_SECONDS`
|
||||
- rows that only expose `event_span_fallback`
|
||||
|
||||
## Personal Player Profile
|
||||
|
||||
The public player profile in `stats.html` can now expose real KPM for the selected weekly or monthly window when the profile endpoint can aggregate:
|
||||
|
||||
- `SUM(player_active_seconds)`
|
||||
- only over rows with:
|
||||
- `active_time_source = connection_intervals`
|
||||
- `active_time_source = connection_intervals_carryover`
|
||||
- `player_active_seconds >= HLL_KPM_MIN_ACTIVE_SECONDS`
|
||||
|
||||
Weekly and monthly profile KPM use:
|
||||
|
||||
```text
|
||||
sum(eligible_kills) / (sum(eligible_player_active_seconds) / 60)
|
||||
```
|
||||
|
||||
This is intentionally narrower than the window total:
|
||||
|
||||
- profile `kills` still show all kills in the selected window
|
||||
- profile `KPM` only becomes `ready` when the active-time subset is reliable enough
|
||||
|
||||
The profile payload now exposes:
|
||||
|
||||
- `player_active_seconds`
|
||||
- `player_active_minutes`
|
||||
- `kpm`
|
||||
- `kpm_status`
|
||||
- `active_time_source`
|
||||
- `active_time_coverage`
|
||||
|
||||
If the profile read model is empty, the endpoint can safely fall back to runtime aggregation over `rcon_match_player_stats` for that single player and time window. This avoids inventing KPM while keeping the page usable before the read model is refreshed.
|
||||
|
||||
## Kills Per Match Labels
|
||||
|
||||
Several public tables were showing `kills_per_match` under the visible label `KPM`.
|
||||
|
||||
@@ -1448,6 +1448,39 @@ h2 {
|
||||
linear-gradient(180deg, rgba(28, 34, 25, 0.88), rgba(12, 15, 11, 0.96));
|
||||
}
|
||||
|
||||
.stats-profile-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.stats-profile-links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border: 1px solid rgba(159, 168, 141, 0.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 18, 13, 0.84);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stats-profile-links a:hover {
|
||||
border-color: rgba(210, 182, 118, 0.34);
|
||||
color: var(--accent-warm);
|
||||
}
|
||||
|
||||
.stats-profile-links__brand {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.stats-summary-card--placeholder {
|
||||
border-color: rgba(210, 182, 118, 0.26);
|
||||
color: var(--accent-warm);
|
||||
|
||||
@@ -22,6 +22,24 @@
|
||||
const annualMetricSupportedOnly = "kills";
|
||||
const annualLimit = 20;
|
||||
const annualServerId = "all";
|
||||
const externalProfileBrands = Object.freeze({
|
||||
steam: Object.freeze({
|
||||
label: "Steam",
|
||||
logoSrc: "./assets/img/brands/steam.png",
|
||||
}),
|
||||
hellor: Object.freeze({
|
||||
label: "Hellor",
|
||||
logoSrc: "./assets/img/brands/hllor.webp",
|
||||
}),
|
||||
hll_records: Object.freeze({
|
||||
label: "HLL Records",
|
||||
logoSrc: "./assets/img/brands/hllrecords.png",
|
||||
}),
|
||||
helo: Object.freeze({
|
||||
label: "Helo",
|
||||
logoSrc: "./assets/img/brands/helo-system.png",
|
||||
}),
|
||||
});
|
||||
|
||||
const messages = {
|
||||
backendUnavailableForSearch:
|
||||
@@ -478,26 +496,30 @@
|
||||
const playerName = String(
|
||||
(weeklyData?.player_name || monthlyData?.player_name || "Sin nombre"),
|
||||
);
|
||||
const playerIdText = String(weeklyData?.player_id || monthlyData?.player_id || playerId);
|
||||
const profileIdentityData =
|
||||
weeklyData?.player_id || weeklyData?.external_profile_links
|
||||
? weeklyData
|
||||
: monthlyData;
|
||||
const hasWeeklyStats = Number(weeklyData?.matches_considered || 0) > 0;
|
||||
const hasMonthlyStats = Number(monthlyData?.matches_considered || 0) > 0;
|
||||
const hasStats = hasWeeklyStats || hasMonthlyStats;
|
||||
const partialLoadWarning = weeklyFailed || monthlyFailed;
|
||||
|
||||
profileStateNode.textContent = hasStats
|
||||
? `${playerName} (${playerIdText})`
|
||||
: `${messages.profileNoStats} (${playerIdText})`;
|
||||
? playerName
|
||||
: messages.profileNoStats;
|
||||
profileStateNode.className = hasStats
|
||||
? "stats-state stats-state--neutral"
|
||||
: "stats-state stats-state--warning";
|
||||
|
||||
summaryGrid.innerHTML = renderProfileSummaryCards({
|
||||
playerName,
|
||||
playerIdText,
|
||||
profileIdentityData,
|
||||
weeklyData,
|
||||
monthlyData,
|
||||
weeklyFailed,
|
||||
monthlyFailed,
|
||||
externalProfileBrands,
|
||||
});
|
||||
|
||||
if (partialLoadWarning) {
|
||||
@@ -557,11 +579,12 @@
|
||||
|
||||
function renderProfileSummaryCards({
|
||||
playerName,
|
||||
playerIdText,
|
||||
profileIdentityData,
|
||||
weeklyData,
|
||||
monthlyData,
|
||||
weeklyFailed,
|
||||
monthlyFailed,
|
||||
externalProfileBrands,
|
||||
}) {
|
||||
const weeklySummary = weeklyFailed
|
||||
? `
|
||||
@@ -599,10 +622,10 @@
|
||||
<article class="stats-summary-card">
|
||||
<p class="stats-summary-title">Identidad</p>
|
||||
<p><strong>Jugador:</strong> ${escapeHtml(playerName)}</p>
|
||||
<p><strong>ID:</strong> ${escapeHtml(playerIdText)}</p>
|
||||
<p><strong>Partidas semanales:</strong> ${safeInt(weeklyData?.matches_considered, 0)}</p>
|
||||
<p><strong>Partidas mensuales:</strong> ${safeInt(monthlyData?.matches_considered, 0)}</p>
|
||||
</article>
|
||||
${renderStatsExternalProfilesCard(profileIdentityData, externalProfileBrands)}
|
||||
${weeklySummary}
|
||||
${monthlySummary}
|
||||
`;
|
||||
@@ -679,6 +702,7 @@
|
||||
const deaths = safeInt(data?.deaths, 0);
|
||||
const kdRatio = safeDecimal(data?.kd_ratio, 2, "0.00");
|
||||
const killsPerMatch = safeDecimal(data?.kills_per_match, 2, "0.00");
|
||||
const kpmMetric = renderStatsKpmMetric(data);
|
||||
|
||||
return `
|
||||
<article class="stats-comparison-card">
|
||||
@@ -704,6 +728,7 @@
|
||||
<span class="stats-comparison-card__metric-label">KD</span>
|
||||
<span class="stats-comparison-card__metric-value">${kdRatio}</span>
|
||||
</div>
|
||||
${kpmMetric}
|
||||
</div>
|
||||
<p class="stats-comparison-card__detail">
|
||||
<strong>Kills/partida:</strong> ${killsPerMatch}
|
||||
@@ -718,6 +743,13 @@
|
||||
const monthlyKillsPerMatch = safeParseNumber(monthlyData?.kills_per_match);
|
||||
const weeklyKd = safeParseNumber(weeklyData?.kd_ratio);
|
||||
const monthlyKd = safeParseNumber(monthlyData?.kd_ratio);
|
||||
const weeklyKpm = safeParseNumber(weeklyData?.kpm);
|
||||
const monthlyKpm = safeParseNumber(monthlyData?.kpm);
|
||||
const hasReadyKpm =
|
||||
weeklyData?.kpm_status === "ready" &&
|
||||
monthlyData?.kpm_status === "ready" &&
|
||||
Number.isFinite(weeklyKpm) &&
|
||||
Number.isFinite(monthlyKpm);
|
||||
const killsDelta = safeInt(monthlyData?.kills, 0) - safeInt(weeklyData?.kills, 0);
|
||||
const matchesDelta =
|
||||
safeInt(monthlyData?.matches_considered, 0) - safeInt(weeklyData?.matches_considered, 0);
|
||||
@@ -746,6 +778,20 @@
|
||||
<span class="stats-comparison-card__metric-label">KD mensual</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(monthlyKd, 2, "0.00")}</span>
|
||||
</div>
|
||||
${
|
||||
hasReadyKpm
|
||||
? `
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">KPM semanal</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(weeklyKpm, 2, "0.00")}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">KPM mensual</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(monthlyKpm, 2, "0.00")}</span>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
<p class="stats-comparison-card__detail">
|
||||
<strong>Delta de kills:</strong> ${formatSignedNumber(killsDelta)}
|
||||
@@ -854,7 +900,7 @@
|
||||
}
|
||||
|
||||
function formatWindowRange(ranking) {
|
||||
const windowLabel = String(ranking?.window_kind || "").trim();
|
||||
const windowLabel = labelForStatsWindowKind(ranking?.window_kind);
|
||||
const windowStart = formatDateTime(ranking?.window_start);
|
||||
const windowEnd = formatDateTime(ranking?.window_end);
|
||||
if (windowLabel) {
|
||||
@@ -875,6 +921,113 @@
|
||||
return labels[String(metric || "").trim()] || "Kills";
|
||||
}
|
||||
|
||||
function labelForStatsWindowKind(windowKind) {
|
||||
const labels = {
|
||||
"current-week": "Semana actual",
|
||||
"current-month": "Mes actual",
|
||||
"previous-closed-week-fallback": "Semana cerrada anterior",
|
||||
"previous-closed-month-fallback": "Mes cerrado anterior",
|
||||
"previous-week": "Semana anterior",
|
||||
"previous-month": "Mes anterior",
|
||||
};
|
||||
const normalized = String(windowKind || "").trim();
|
||||
return labels[normalized] || normalized;
|
||||
}
|
||||
|
||||
function renderStatsKpmMetric(data) {
|
||||
if (data?.kpm_status !== "ready") {
|
||||
return "";
|
||||
}
|
||||
const kpmValue = safeParseNumber(data?.kpm);
|
||||
if (!Number.isFinite(kpmValue)) {
|
||||
return "";
|
||||
}
|
||||
return `
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">KPM</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(kpmValue, 2, "0.00")}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStatsExternalProfilesCard(profileData, brands) {
|
||||
const links = resolveStatsExternalProfileLinks(profileData, brands);
|
||||
return `
|
||||
<article class="stats-summary-card">
|
||||
<p class="stats-summary-title">Perfiles externos</p>
|
||||
${
|
||||
links.length
|
||||
? `
|
||||
<div class="stats-profile-links">
|
||||
${links
|
||||
.map(
|
||||
({ href, label, logoSrc }) => `
|
||||
<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">
|
||||
${
|
||||
logoSrc
|
||||
? `<img class="stats-profile-links__brand" src="${escapeHtml(logoSrc)}" alt="" aria-hidden="true" decoding="async" loading="lazy" onerror="this.remove();" />`
|
||||
: ""
|
||||
}
|
||||
<span>${escapeHtml(label)}</span>
|
||||
</a>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`
|
||||
: "<p>Perfiles externos no disponibles.</p>"
|
||||
}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function resolveStatsExternalProfileLinks(profileData, brands) {
|
||||
const links = [];
|
||||
const fallbackLinks = buildFallbackExternalProfileLinks(profileData);
|
||||
const payloadLinks =
|
||||
profileData?.external_profile_links && typeof profileData.external_profile_links === "object"
|
||||
? profileData.external_profile_links
|
||||
: {};
|
||||
const candidateLinks = {
|
||||
...fallbackLinks,
|
||||
...payloadLinks,
|
||||
};
|
||||
["steam", "hellor", "hll_records", "helo"].forEach((key) => {
|
||||
const href = candidateLinks?.[key];
|
||||
if (typeof href !== "string" || !href.trim()) {
|
||||
return;
|
||||
}
|
||||
const brand = brands[key];
|
||||
links.push({
|
||||
href,
|
||||
label: brand?.label || key,
|
||||
logoSrc: brand?.logoSrc || "",
|
||||
});
|
||||
});
|
||||
return links;
|
||||
}
|
||||
|
||||
function buildFallbackExternalProfileLinks(profileData) {
|
||||
const playerId = String(profileData?.player_id || "").trim();
|
||||
const steamId = String(profileData?.steam_id_64 || playerId).trim();
|
||||
if (/^\d{17}$/.test(steamId)) {
|
||||
return {
|
||||
steam: `https://steamcommunity.com/profiles/${steamId}`,
|
||||
hellor: `https://hellor.pro/player/${steamId}`,
|
||||
hll_records: `https://hllrecords.com/profiles/${steamId}`,
|
||||
helo: `https://helo-system.de/statistics/players/${steamId}?series=2024`,
|
||||
};
|
||||
}
|
||||
const epicId = String(profileData?.epic_id || playerId).trim().toLowerCase();
|
||||
if (/^[0-9a-f]{32}$/i.test(epicId)) {
|
||||
return {
|
||||
hellor: `https://hellor.pro/player/${epicId}`,
|
||||
hll_records: `https://hllrecords.com/profiles/${epicId}`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function safeInt(value, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
|
||||
Reference in New Issue
Block a user