diff --git a/ai/tasks/done/TASK-146-match-detail-scoreboard-and-player-links.md b/ai/tasks/done/TASK-146-match-detail-scoreboard-and-player-links.md new file mode 100644 index 0000000..6320516 --- /dev/null +++ b/ai/tasks/done/TASK-146-match-detail-scoreboard-and-player-links.md @@ -0,0 +1,141 @@ +--- +id: TASK-146 +title: Recover match scoreboard and player external links +status: done +type: backend +team: Backend Senior +supporting_teams: [Frontend Senior] +roadmap_item: foundation +priority: high +--- + +# TASK-146 - Recover match scoreboard and player external links + +## Goal + +Restore the safe public scoreboard action on the internal match detail page and +expose player external profile links only when captured player identifiers make +that reliable. + +## Context + +The internal `historico-partida.html` detail payload can carry a safe +`match_url`, but users do not currently see the detail-page scoreboard action. +The same detail page has click-open player panels that can link to Steam and +SteamID64-based third-party profiles when the RCON/profile or reliable +historical data path already holds a valid SteamID64. + +## Steps + +1. Inspect the listed files and captured identifier path first. +2. Keep scoreboard actions detail-page only and add profile links only from + reliable identifiers. +3. Validate backend diagnostics, frontend syntax, integration/container checks + and rendered detail-page behavior. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `ai/orchestrator/frontend-senior.md` +- `backend/app/rcon_historical_read_model.py` +- `frontend/assets/js/historico-partida.js` + +## Expected Files to Modify + +- Relevant backend read-model, storage helper, diagnostics and tests for exposed + external player identifiers and profile links. +- `frontend/assets/js/historico-partida.js` +- Relevant detail-page CSS when profile links need styling. +- `ai/tasks/done/TASK-146-match-detail-scoreboard-and-player-links.md` + +## Constraints + +- Do not invent SteamIDs or use player names to build external links. +- Do not call Steam Web API or require new external credentials. +- Keep public scoreboard actions off recent match cards. +- Allow only safe Comunidad Hispana scoreboard match URLs in the detail page. +- Preserve click-open player panels and existing expanded stat sections. + +## Validation + +- `python -m compileall backend/app` +- `python -m app.storage_diagnostics` +- `node --check frontend/assets/js/historico-partida.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend` +- Inspect the provided match-detail API response with `Invoke-WebRequest`. +- Render-check the detail-page scoreboard action, player panel profile state and + recent-match-card absence of public scoreboard links. +- Review `git diff --name-only` against task scope. + +## Outcome + +Implemented. + +- The internal match detail scoreboard action now sits immediately after the + summary block and before the potentially long player table. It still uses the + existing detail-page safe scoreboard allowlist and keeps the established + `Ver en Scoreboard` regression-checked label. +- RCON detail rows derive `platform`, `steam_id_64` and Steam/Hellor/HLL Records + URLs from captured `player_id` only when it is a valid numeric 17-digit + SteamID64. Raw RCON `player_id` is still not exposed. +- Trusted public-scoreboard detail rows derive the same profile fields from + persisted `historical_players.steam_id`. +- Epic-style hex RCON IDs resolve to `platform: epic` with no Steam-only links; + other missing or unsupported IDs return `platform: unknown` with no external + link fields. +- Click-open player panels now include `Perfiles externos`. Steam-backed players + get the three external links with new-tab noopener rel attributes; missing-ID + panels show a clear unavailable state without using player names to build + URLs. +- PostgreSQL storage diagnostics now report SteamID64 availability counts for + RCON match rows, RCON profile snapshots and trusted scoreboard player rows. + +Validation passed: + +- `python -m compileall backend/app` +- `python -m app.storage_diagnostics` from `backend/` in local SQLite fallback + mode. It reports PostgreSQL external-ID diagnostics as inactive when the + PostgreSQL env is not configured locally. +- `docker compose exec backend python -m app.storage_diagnostics` after the + advanced stack build. PostgreSQL diagnostics reported: + - `rcon_match_steam_id64_rows`: `2554` + - `rcon_profile_steam_id64_rows`: `462` + - `scoreboard_player_steam_id64_rows`: `87369` +- `node --check frontend/assets/js/historico-partida.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- Targeted backend tests from `backend/`: + `python -m unittest tests.test_rcon_materialization_pipeline tests.test_scoreboard_match_links` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend` +- The provided `Invoke-WebRequest` match-detail API check returned the safe + Carentan scoreboard URL plus validated external profile links for captured + SteamID64-backed players. +- Rendered local Chrome/Selenium fallback QA verified: + - detail scoreboard action appears before the player table and opens + `https://scoreboard.comunidadhll.es:5443/games/1562094` + - Steam-backed player panel shows Steam, Hellor and HLL Records links + - a no-SteamID64 player panel shows `Perfiles externos no disponibles` + - recent `historico.html` match cards show no public scoreboard links + - mobile detail layout keeps the scoreboard action readable + +Notes: + +- Browser plugin runtime tools were not exposed by tool discovery in this + session, so rendered QA used temporary headless Chrome/Selenium checks and + screenshots outside the repository. +- Targeted backend unittest output still includes existing SQLite resource + warnings from the current test suite while tests pass. +- Browser console output on rendered checks only reported the existing missing + `favicon.ico` request. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/backend/app/historical_storage.py b/backend/app/historical_storage.py index f8c4674..4e5be7b 100644 --- a/backend/app/historical_storage.py +++ b/backend/app/historical_storage.py @@ -17,6 +17,7 @@ from .config import ( from .historical_models import HistoricalServerDefinition from .monthly_mvp import build_monthly_mvp_rankings from .monthly_mvp_v2 import build_monthly_mvp_v2_rankings +from .player_external_profiles import build_external_player_profile_fields from .scoreboard_origins import ( list_trusted_public_scoreboard_origins, resolve_trusted_scoreboard_match_url, @@ -872,6 +873,7 @@ def get_historical_match_detail( SELECT historical_players.display_name, historical_players.stable_player_key, + historical_players.steam_id, historical_player_match_stats.team_side, historical_player_match_stats.level, historical_player_match_stats.kills, @@ -925,6 +927,7 @@ def get_historical_match_detail( "name": player_row["display_name"], "stable_player_key": player_row["stable_player_key"], "team_side": player_row["team_side"], + **build_external_player_profile_fields(steam_id=player_row["steam_id"]), "level": _coerce_int(player_row["level"]), "kills": _coerce_int(player_row["kills"]), "deaths": _coerce_int(player_row["deaths"]), diff --git a/backend/app/player_external_profiles.py b/backend/app/player_external_profiles.py new file mode 100644 index 0000000..27a47f7 --- /dev/null +++ b/backend/app/player_external_profiles.py @@ -0,0 +1,43 @@ +"""Safe external profile fields derived from captured player identifiers.""" + +from __future__ import annotations + +import re + + +_STEAM_ID64_RE = re.compile(r"^\d{17}$") +_EPIC_ID_RE = re.compile(r"^[0-9a-f]{32}$", re.IGNORECASE) + + +def build_external_player_profile_fields( + *, + player_id: object = None, + steam_id: object = None, +) -> dict[str, object]: + """Expose player profile links only when a captured SteamID64 is valid.""" + steam_id_64 = normalize_steam_id_64(steam_id) or normalize_steam_id_64(player_id) + if steam_id_64: + return { + "steam_id_64": steam_id_64, + "platform": "steam", + "external_profile_links": { + "steam": f"https://steamcommunity.com/profiles/{steam_id_64}", + "hellor": f"https://hellor.pro/player/{steam_id_64}", + "hll_records": f"https://hllrecords.com/profiles/{steam_id_64}", + }, + } + return {"platform": infer_player_platform(player_id=player_id, steam_id=steam_id)} + + +def normalize_steam_id_64(value: object) -> str | None: + normalized = str(value or "").strip() + return normalized if _STEAM_ID64_RE.fullmatch(normalized) else None + + +def infer_player_platform(*, player_id: object = None, steam_id: object = None) -> str: + normalized_player_id = str(player_id or "").strip() + if normalize_steam_id_64(steam_id) or normalize_steam_id_64(normalized_player_id): + return "steam" + if _EPIC_ID_RE.fullmatch(normalized_player_id): + return "epic" + return "unknown" diff --git a/backend/app/postgres_display_storage.py b/backend/app/postgres_display_storage.py index 2b73044..af06a6b 100644 --- a/backend/app/postgres_display_storage.py +++ b/backend/app/postgres_display_storage.py @@ -10,6 +10,7 @@ from typing import Any, Iterable, Mapping from .config import get_database_url, get_historical_weekly_fallback_max_weekday from .historical_models import HistoricalSnapshotRecord +from .player_external_profiles import build_external_player_profile_fields from .scoreboard_origins import resolve_trusted_scoreboard_match_url @@ -518,7 +519,7 @@ def get_scoreboard_match_detail(*, server_slug: str, match_id: str) -> dict[str, return None players = connection.execute( """ - SELECT hp.display_name, hp.stable_player_key, stats.team_side, stats.level, + SELECT hp.display_name, hp.stable_player_key, hp.steam_id, stats.team_side, stats.level, stats.kills, stats.deaths, stats.teamkills, stats.combat, stats.offense, stats.defense, stats.support, stats.time_seconds FROM historical_player_match_stats AS stats @@ -546,6 +547,7 @@ def get_scoreboard_match_detail(*, server_slug: str, match_id: str) -> dict[str, "name": player["display_name"], "stable_player_key": player["stable_player_key"], "team_side": player["team_side"], + **build_external_player_profile_fields(steam_id=player["steam_id"]), **{ key: _int(player[key]) for key in ( diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index cf8a85f..20ba791 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone from .historical_storage import ALL_SERVERS_SLUG from .normalizers import normalize_map_name +from .player_external_profiles import build_external_player_profile_fields from .rcon_scoreboard_correlation import resolve_rcon_scoreboard_match_url from .rcon_historical_storage import ( find_rcon_historical_competitive_window, @@ -319,6 +320,7 @@ def _build_player_row( "top_weapons": _top_counter(row.get("weapons_json")), "most_killed": _top_counter(row.get("most_killed_json")), "death_by": _top_counter(row.get("death_by_json")), + **build_external_player_profile_fields(player_id=row.get("player_id")), } if profile_summary: player["profile_summary"] = profile_summary diff --git a/backend/app/storage_diagnostics.py b/backend/app/storage_diagnostics.py index 6309e98..1ee8042 100644 --- a/backend/app/storage_diagnostics.py +++ b/backend/app/storage_diagnostics.py @@ -99,6 +99,12 @@ def build_storage_diagnostics() -> dict[str, object]: "paused Elo/MMR tables", ], "scoreboard_correlation": "PostgreSQL safe candidates and migrated trusted historical match URLs are used.", + "external_player_ids": _postgres_external_player_id_diagnostics() + if backend == "postgresql" + else { + "available_in_postgresql": False, + "reason": "PostgreSQL storage is not active.", + }, "migration_parity_summary": { "available": backend == "postgresql", "source_command": "python -m app.sqlite_to_postgres_migration", @@ -125,6 +131,31 @@ def _count_sqlite_tables() -> dict[str, int]: return counts +def _postgres_external_player_id_diagnostics() -> dict[str, object]: + from .postgres_rcon_storage import connect_postgres + + with connect_postgres() as connection: + row = connection.execute( + """ + SELECT + (SELECT COUNT(*) FROM rcon_match_player_stats + WHERE player_id ~ '^[0-9]{17}$') AS rcon_match_steam_id64_rows, + (SELECT COUNT(*) FROM rcon_player_profile_snapshots + WHERE player_id ~ '^[0-9]{17}$') AS rcon_profile_steam_id64_rows, + (SELECT COUNT(*) FROM historical_players + WHERE steam_id ~ '^[0-9]{17}$') AS scoreboard_player_steam_id64_rows + """ + ).fetchone() + return { + "available_in_postgresql": True, + "rcon_match_steam_id64_rows": int(row["rcon_match_steam_id64_rows"] or 0), + "rcon_profile_steam_id64_rows": int(row["rcon_profile_steam_id64_rows"] or 0), + "scoreboard_player_steam_id64_rows": int( + row["scoreboard_player_steam_id64_rows"] or 0 + ), + } + + def main() -> None: print(json.dumps(build_storage_diagnostics(), ensure_ascii=False, indent=2, default=str)) diff --git a/backend/tests/test_rcon_materialization_pipeline.py b/backend/tests/test_rcon_materialization_pipeline.py index 4f1c04d..6e1b9cf 100644 --- a/backend/tests/test_rcon_materialization_pipeline.py +++ b/backend/tests/test_rcon_materialization_pipeline.py @@ -74,8 +74,18 @@ class RconMaterializationPipelineTests(unittest.TestCase): self.assertEqual(detail["result_source"], "admin-log-match-ended") self.assertEqual(detail["result"]["allied_score"], 5) self.assertEqual(detail["timestamp_confidence"], "absolute") - self.assertNotIn("player_id", detail["players"][0]) - self.assertIn("kd_ratio", detail["players"][0]) + players = {row["player_name"]: row for row in detail["players"]} + self.assertNotIn("player_id", players["Alpha"]) + self.assertIn("kd_ratio", players["Alpha"]) + self.assertEqual(players["Alpha"]["steam_id_64"], "76561198000000001") + self.assertEqual(players["Alpha"]["platform"], "steam") + self.assertEqual( + players["Alpha"]["external_profile_links"]["hellor"], + "https://hellor.pro/player/76561198000000001", + ) + self.assertEqual(players["Charlie"]["platform"], "unknown") + self.assertNotIn("steam_id_64", players["Charlie"]) + self.assertNotIn("external_profile_links", players["Charlie"]) gc.collect() def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(self) -> None: diff --git a/backend/tests/test_scoreboard_match_links.py b/backend/tests/test_scoreboard_match_links.py index da68f44..210e00e 100644 --- a/backend/tests/test_scoreboard_match_links.py +++ b/backend/tests/test_scoreboard_match_links.py @@ -70,6 +70,40 @@ class PersistedScoreboardMatchLinkTests(unittest.TestCase): self.assertIsNone(detail["match_url"]) gc.collect() + def test_detail_player_links_use_trusted_scoreboard_steam_id(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + _persist_match( + db_path, + server_slug="comunidad-hispana-02", + match_id="steam-player-match", + player_stats=[ + { + "player": "Steam Player", + "steaminfo": {"profile": {"steamid": "76561198000000009"}}, + "team": {"side": "allies"}, + "kills": 4, + "deaths": 2, + } + ], + ) + + detail = get_historical_match_detail( + server_slug="comunidad-hispana-02", + match_id="steam-player-match", + db_path=db_path, + ) + + self.assertIsNotNone(detail) + player = detail["players"][0] + self.assertEqual(player["steam_id_64"], "76561198000000009") + self.assertEqual(player["platform"], "steam") + self.assertEqual( + player["external_profile_links"]["hll_records"], + "https://hllrecords.com/profiles/76561198000000009", + ) + gc.collect() + def test_rcon_match_detail_does_not_fabricate_external_scoreboard_url(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "historical.sqlite3" @@ -176,6 +210,7 @@ def _persist_match( map_name: str = "carentan", started_at: str = "2026-05-01T10:00:00Z", ended_at: str = "2026-05-01T11:20:00Z", + player_stats: list[dict[str, object]] | None = None, ) -> None: upsert_historical_match( server_slug=server_slug, @@ -186,7 +221,7 @@ def _persist_match( "end": ended_at, "map": {"name": map_name}, "result": {"allied": 3, "axis": 2}, - "player_stats": [], + "player_stats": player_stats or [], }, db_path=db_path, ) diff --git a/frontend/assets/css/historico-scoreboard-detail.css b/frontend/assets/css/historico-scoreboard-detail.css index 3b2afbf..d17645a 100644 --- a/frontend/assets/css/historico-scoreboard-detail.css +++ b/frontend/assets/css/historico-scoreboard-detail.css @@ -197,6 +197,37 @@ grid-column: 1 / -1; } +.historical-player-stats-panel__profiles { + padding: 13px; +} + +.historical-player-profile-links { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.historical-player-profile-links a { + display: inline-flex; + min-height: 32px; + align-items: center; + padding: 0 11px; + border: 1px solid rgba(210, 182, 118, 0.34); + border-radius: 999px; + color: var(--accent-warm); + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.08em; + text-decoration: none; + text-transform: uppercase; +} + +.historical-player-profile-links a:hover, +.historical-player-profile-links a:focus-visible { + border-color: rgba(210, 182, 118, 0.62); + color: var(--text); +} + .historical-player-stats-panel__section h5 { font-size: 0.78rem; letter-spacing: 0.08em; diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index 74a0d63..9a3695c 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -324,6 +324,7 @@ function renderPlayerStatsPanel(player, item, context) { ${renderPlayerStatChip("KPM", context.kpm)} + ${renderExternalProfilesSection(player)} ${ hasExpandedStats ? ` @@ -340,6 +341,39 @@ function renderPlayerStatsPanel(player, item, context) { `; } +function renderExternalProfilesSection(player) { + const links = [ + ["steam", "Steam"], + ["hellor", "Hellor"], + ["hll_records", "HLL Records"], + ] + .map(([key, label]) => [label, player.external_profile_links?.[key]]) + .filter(([, href]) => typeof href === "string" && href.trim()); + + return ` +
+
Perfiles externos
+ ${ + links.length + ? ` + + ` + : "

Perfiles externos no disponibles.

" + } +
+ `; +} + function renderPlayerStatChip(label, value) { return `
@@ -454,6 +488,7 @@ function renderActions(item, actionsNode) { actionsNode.innerHTML = ` +