feat: add scoreboard and player external links

This commit is contained in:
devRaGonSa
2026-05-21 10:22:18 +02:00
parent 8cff65dda6
commit 1b3b948ded
11 changed files with 338 additions and 5 deletions

View File

@@ -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"]),

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

View File

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

View File

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

View File

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

View File

@@ -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,
)