Merge remote-tracking branch 'origin/codex/match-detail-scoreboard-and-player-links'

This commit is contained in:
devRaGonSa
2026-05-21 10:31:04 +02:00
11 changed files with 338 additions and 5 deletions

View File

@@ -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.

View File

@@ -17,6 +17,7 @@ from .config import (
from .historical_models import HistoricalServerDefinition from .historical_models import HistoricalServerDefinition
from .monthly_mvp import build_monthly_mvp_rankings from .monthly_mvp import build_monthly_mvp_rankings
from .monthly_mvp_v2 import build_monthly_mvp_v2_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 ( from .scoreboard_origins import (
list_trusted_public_scoreboard_origins, list_trusted_public_scoreboard_origins,
resolve_trusted_scoreboard_match_url, resolve_trusted_scoreboard_match_url,
@@ -872,6 +873,7 @@ def get_historical_match_detail(
SELECT SELECT
historical_players.display_name, historical_players.display_name,
historical_players.stable_player_key, historical_players.stable_player_key,
historical_players.steam_id,
historical_player_match_stats.team_side, historical_player_match_stats.team_side,
historical_player_match_stats.level, historical_player_match_stats.level,
historical_player_match_stats.kills, historical_player_match_stats.kills,
@@ -925,6 +927,7 @@ def get_historical_match_detail(
"name": player_row["display_name"], "name": player_row["display_name"],
"stable_player_key": player_row["stable_player_key"], "stable_player_key": player_row["stable_player_key"],
"team_side": player_row["team_side"], "team_side": player_row["team_side"],
**build_external_player_profile_fields(steam_id=player_row["steam_id"]),
"level": _coerce_int(player_row["level"]), "level": _coerce_int(player_row["level"]),
"kills": _coerce_int(player_row["kills"]), "kills": _coerce_int(player_row["kills"]),
"deaths": _coerce_int(player_row["deaths"]), "deaths": _coerce_int(player_row["deaths"]),

View File

@@ -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"

View File

@@ -10,6 +10,7 @@ from typing import Any, Iterable, Mapping
from .config import get_database_url, get_historical_weekly_fallback_max_weekday from .config import get_database_url, get_historical_weekly_fallback_max_weekday
from .historical_models import HistoricalSnapshotRecord from .historical_models import HistoricalSnapshotRecord
from .player_external_profiles import build_external_player_profile_fields
from .scoreboard_origins import resolve_trusted_scoreboard_match_url 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 return None
players = connection.execute( 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.kills, stats.deaths, stats.teamkills, stats.combat, stats.offense,
stats.defense, stats.support, stats.time_seconds stats.defense, stats.support, stats.time_seconds
FROM historical_player_match_stats AS stats 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"], "name": player["display_name"],
"stable_player_key": player["stable_player_key"], "stable_player_key": player["stable_player_key"],
"team_side": player["team_side"], "team_side": player["team_side"],
**build_external_player_profile_fields(steam_id=player["steam_id"]),
**{ **{
key: _int(player[key]) key: _int(player[key])
for key in ( for key in (

View File

@@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone
from .historical_storage import ALL_SERVERS_SLUG from .historical_storage import ALL_SERVERS_SLUG
from .normalizers import normalize_map_name 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_scoreboard_correlation import resolve_rcon_scoreboard_match_url
from .rcon_historical_storage import ( from .rcon_historical_storage import (
find_rcon_historical_competitive_window, find_rcon_historical_competitive_window,
@@ -319,6 +320,7 @@ def _build_player_row(
"top_weapons": _top_counter(row.get("weapons_json")), "top_weapons": _top_counter(row.get("weapons_json")),
"most_killed": _top_counter(row.get("most_killed_json")), "most_killed": _top_counter(row.get("most_killed_json")),
"death_by": _top_counter(row.get("death_by_json")), "death_by": _top_counter(row.get("death_by_json")),
**build_external_player_profile_fields(player_id=row.get("player_id")),
} }
if profile_summary: if profile_summary:
player["profile_summary"] = profile_summary player["profile_summary"] = profile_summary

View File

@@ -99,6 +99,12 @@ def build_storage_diagnostics() -> dict[str, object]:
"paused Elo/MMR tables", "paused Elo/MMR tables",
], ],
"scoreboard_correlation": "PostgreSQL safe candidates and migrated trusted historical match URLs are used.", "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": { "migration_parity_summary": {
"available": backend == "postgresql", "available": backend == "postgresql",
"source_command": "python -m app.sqlite_to_postgres_migration", "source_command": "python -m app.sqlite_to_postgres_migration",
@@ -125,6 +131,31 @@ def _count_sqlite_tables() -> dict[str, int]:
return counts 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: def main() -> None:
print(json.dumps(build_storage_diagnostics(), ensure_ascii=False, indent=2, default=str)) print(json.dumps(build_storage_diagnostics(), ensure_ascii=False, indent=2, default=str))

View File

@@ -74,8 +74,18 @@ class RconMaterializationPipelineTests(unittest.TestCase):
self.assertEqual(detail["result_source"], "admin-log-match-ended") self.assertEqual(detail["result_source"], "admin-log-match-ended")
self.assertEqual(detail["result"]["allied_score"], 5) self.assertEqual(detail["result"]["allied_score"], 5)
self.assertEqual(detail["timestamp_confidence"], "absolute") self.assertEqual(detail["timestamp_confidence"], "absolute")
self.assertNotIn("player_id", detail["players"][0]) players = {row["player_name"]: row for row in detail["players"]}
self.assertIn("kd_ratio", detail["players"][0]) 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() gc.collect()
def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(self) -> None: def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(self) -> None:

View File

@@ -70,6 +70,40 @@ class PersistedScoreboardMatchLinkTests(unittest.TestCase):
self.assertIsNone(detail["match_url"]) self.assertIsNone(detail["match_url"])
gc.collect() 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: def test_rcon_match_detail_does_not_fabricate_external_scoreboard_url(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3" db_path = Path(tmpdir) / "historical.sqlite3"
@@ -176,6 +210,7 @@ def _persist_match(
map_name: str = "carentan", map_name: str = "carentan",
started_at: str = "2026-05-01T10:00:00Z", started_at: str = "2026-05-01T10:00:00Z",
ended_at: str = "2026-05-01T11:20:00Z", ended_at: str = "2026-05-01T11:20:00Z",
player_stats: list[dict[str, object]] | None = None,
) -> None: ) -> None:
upsert_historical_match( upsert_historical_match(
server_slug=server_slug, server_slug=server_slug,
@@ -186,7 +221,7 @@ def _persist_match(
"end": ended_at, "end": ended_at,
"map": {"name": map_name}, "map": {"name": map_name},
"result": {"allied": 3, "axis": 2}, "result": {"allied": 3, "axis": 2},
"player_stats": [], "player_stats": player_stats or [],
}, },
db_path=db_path, db_path=db_path,
) )

View File

@@ -253,6 +253,37 @@
grid-column: 1 / -1; 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 { .historical-player-stats-panel__section h5 {
font-size: 0.78rem; font-size: 0.78rem;
letter-spacing: 0.08em; letter-spacing: 0.08em;

View File

@@ -490,6 +490,7 @@ function renderPlayerStatsPanel(player, item, context) {
${renderPlayerStatChip("KPM", context.kpm)} ${renderPlayerStatChip("KPM", context.kpm)}
</div> </div>
</div> </div>
${renderExternalProfilesSection(player)}
${ ${
hasExpandedStats hasExpandedStats
? ` ? `
@@ -506,6 +507,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 `
<article class="historical-player-stats-panel__section historical-player-stats-panel__profiles">
<h5>Perfiles externos</h5>
${
links.length
? `
<div class="historical-player-profile-links">
${links
.map(
([label, href]) => `
<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">
${escapeHtml(label)}
</a>
`,
)
.join("")}
</div>
`
: "<p>Perfiles externos no disponibles.</p>"
}
</article>
`;
}
function renderPlayerStatChip(label, value) { function renderPlayerStatChip(label, value) {
return ` return `
<article> <article>
@@ -620,6 +654,7 @@ function renderActions(item, actionsNode) {
actionsNode.innerHTML = ` actionsNode.innerHTML = `
<a <a
class="historical-match-card__link" class="historical-match-card__link"
data-match-detail-scoreboard-link
href="${escapeHtml(matchUrl)}" href="${escapeHtml(matchUrl)}"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"

View File

@@ -76,6 +76,7 @@
Cargando detalle... Cargando detalle...
</p> </p>
<div class="historical-summary-grid" id="match-detail-grid" hidden></div> <div class="historical-summary-grid" id="match-detail-grid" hidden></div>
<div class="historical-match-card__actions" id="match-detail-actions" hidden></div>
<div class="historical-detail-section" id="match-detail-players-section" hidden> <div class="historical-detail-section" id="match-detail-players-section" hidden>
<div class="historical-detail-section__header"> <div class="historical-detail-section__header">
<div> <div>
@@ -148,7 +149,6 @@
</table> </table>
</div> </div>
</div> </div>
<div class="historical-match-card__actions" id="match-detail-actions" hidden></div>
</div> </div>
</section> </section>
</main> </main>