feat: resolve scoreboard links and enrich match details

Implement trusted scoreboard origins, persisted scoreboard links, RCON-to-scoreboard correlation, match link UX priority, enriched internal match details, and historical UI regression validation.
This commit is contained in:
devRaGonSa
2026-05-19 11:13:17 +02:00
committed by GitHub
parent af4de29a0a
commit 95bb09118f
19 changed files with 1851 additions and 48 deletions

View File

@@ -6,7 +6,6 @@ import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Mapping
from urllib.parse import urlparse
from .config import (
get_historical_refresh_overlap_hours,
@@ -17,28 +16,22 @@ 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 .scoreboard_origins import (
list_trusted_public_scoreboard_origins,
resolve_trusted_scoreboard_match_url,
)
from .sqlite_utils import connect_sqlite_writer
DEFAULT_HISTORICAL_SERVERS = (
DEFAULT_HISTORICAL_SERVERS = tuple(
HistoricalServerDefinition(
slug="comunidad-hispana-01",
display_name="Comunidad Hispana #01",
scoreboard_base_url="https://scoreboard.comunidadhll.es",
server_number=1,
),
HistoricalServerDefinition(
slug="comunidad-hispana-02",
display_name="Comunidad Hispana #02",
scoreboard_base_url="https://scoreboard.comunidadhll.es:5443",
server_number=2,
),
HistoricalServerDefinition(
slug="comunidad-hispana-03",
display_name="Comunidad Hispana #03",
scoreboard_base_url="https://scoreboard.comunidadhll.es:3443",
server_number=3,
),
slug=origin.slug,
display_name=origin.display_name,
scoreboard_base_url=origin.base_url,
server_number=origin.server_number,
source_kind=origin.source_kind,
)
for origin in list_trusted_public_scoreboard_origins()
)
ALL_SERVERS_SLUG = "all-servers"
ALL_SERVERS_DISPLAY_NAME = "Todos"
@@ -768,6 +761,7 @@ def list_recent_historical_matches(
historical_matches.allied_score,
historical_matches.axis_score,
historical_matches.raw_payload_ref,
historical_servers.slug,
historical_servers.scoreboard_base_url,
COUNT(historical_player_match_stats.id) AS player_count
FROM historical_matches
@@ -809,7 +803,7 @@ def list_recent_historical_matches(
"player_count": int(row["player_count"] or 0),
"match_url": _resolve_safe_match_url(
row["raw_payload_ref"],
row["scoreboard_base_url"],
row["server_slug"],
),
}
)
@@ -832,6 +826,7 @@ def get_historical_match_detail(
row = connection.execute(
"""
SELECT
historical_matches.id AS match_pk,
historical_servers.slug AS server_slug,
historical_servers.display_name AS server_name,
historical_matches.external_match_id,
@@ -842,6 +837,7 @@ def get_historical_match_detail(
historical_matches.allied_score,
historical_matches.axis_score,
historical_matches.raw_payload_ref,
historical_servers.slug,
historical_servers.scoreboard_base_url,
COUNT(historical_player_match_stats.id) AS player_count,
SUM(COALESCE(historical_player_match_stats.time_seconds, 0)) AS total_time_seconds
@@ -857,6 +853,33 @@ def get_historical_match_detail(
""",
(normalized_server_slug, normalized_match_id),
).fetchone()
player_rows = []
if row is not None:
player_rows = connection.execute(
"""
SELECT
historical_players.display_name,
historical_players.stable_player_key,
historical_player_match_stats.team_side,
historical_player_match_stats.level,
historical_player_match_stats.kills,
historical_player_match_stats.deaths,
historical_player_match_stats.teamkills,
historical_player_match_stats.combat,
historical_player_match_stats.offense,
historical_player_match_stats.defense,
historical_player_match_stats.support,
historical_player_match_stats.time_seconds
FROM historical_player_match_stats
INNER JOIN historical_players
ON historical_players.id = historical_player_match_stats.historical_player_id
WHERE historical_player_match_stats.historical_match_id = ?
ORDER BY
COALESCE(historical_player_match_stats.kills, 0) DESC,
historical_players.display_name ASC
""",
(row["match_pk"],),
).fetchall()
if row is None:
return None
started_at = row["started_at"]
@@ -885,10 +908,27 @@ def get_historical_match_detail(
},
"player_count": int(row["player_count"] or 0),
"total_time_seconds": _coerce_int(row["total_time_seconds"]),
"players": [
{
"name": player_row["display_name"],
"stable_player_key": player_row["stable_player_key"],
"team_side": player_row["team_side"],
"level": _coerce_int(player_row["level"]),
"kills": _coerce_int(player_row["kills"]),
"deaths": _coerce_int(player_row["deaths"]),
"teamkills": _coerce_int(player_row["teamkills"]),
"combat": _coerce_int(player_row["combat"]),
"offense": _coerce_int(player_row["offense"]),
"defense": _coerce_int(player_row["defense"]),
"support": _coerce_int(player_row["support"]),
"time_seconds": _coerce_int(player_row["time_seconds"]),
}
for player_row in player_rows
],
"capture_basis": "public-scoreboard-match",
"match_url": _resolve_safe_match_url(
row["raw_payload_ref"],
row["scoreboard_base_url"],
row["server_slug"],
),
}
@@ -3198,23 +3238,8 @@ def _is_all_servers_selector(value: str | None) -> bool:
return isinstance(value, str) and value.strip() == ALL_SERVERS_SLUG
def _resolve_safe_match_url(raw_payload_ref: object, scoreboard_base_url: object) -> str | None:
candidate = _stringify(raw_payload_ref)
base_url = _stringify(scoreboard_base_url)
if not candidate or not base_url:
return None
candidate_parts = urlparse(candidate)
base_parts = urlparse(base_url)
if candidate_parts.scheme not in {"http", "https"}:
return None
if candidate_parts.scheme != base_parts.scheme or candidate_parts.netloc != base_parts.netloc:
return None
if not candidate_parts.path.startswith("/games/"):
return None
if candidate_parts.username or candidate_parts.password:
return None
return candidate
def _resolve_safe_match_url(raw_payload_ref: object, server_slug: object) -> str | None:
return resolve_trusted_scoreboard_match_url(raw_payload_ref, server_slug)
def _calculate_match_duration_seconds(started_at: object, ended_at: object) -> int | None:

View File

@@ -39,7 +39,6 @@ from .historical_snapshots import (
)
from .historical_storage import (
ALL_SERVERS_SLUG,
DEFAULT_HISTORICAL_SERVERS,
get_historical_match_detail,
get_historical_player_profile,
list_historical_server_summaries,
@@ -50,6 +49,7 @@ from .historical_storage import (
)
from .rcon_historical_read_model import get_rcon_historical_match_detail
from .normalizers import normalize_map_name
from .scoreboard_origins import get_trusted_public_scoreboard_origin
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
@@ -1910,7 +1910,5 @@ def _resolve_community_history_url(external_server_id: object) -> str | None:
normalized_server_id = str(external_server_id or "").strip()
if not normalized_server_id:
return None
for server in DEFAULT_HISTORICAL_SERVERS:
if server.slug == normalized_server_id:
return f"{server.scoreboard_base_url}/games"
return None
origin = get_trusted_public_scoreboard_origin(normalized_server_id)
return f"{origin.base_url}/games" if origin else None

View File

@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from .historical_storage import ALL_SERVERS_SLUG
from .normalizers import normalize_map_name
from .rcon_scoreboard_correlation import resolve_rcon_scoreboard_match_url
from .rcon_historical_storage import (
find_rcon_historical_competitive_window,
get_rcon_historical_competitive_window_by_session,
@@ -136,6 +137,8 @@ def get_rcon_historical_match_detail(
)
if item is None:
return None
player_count = int(round(float(item.get("average_players") or 0)))
server_slug = item["external_server_id"] or item["target_key"]
return {
"server": {
"slug": item["target_key"],
@@ -160,9 +163,18 @@ def get_rcon_historical_match_detail(
"player_count": int(round(float(item.get("average_players") or 0))),
"peak_players": item.get("peak_players"),
"sample_count": item.get("sample_count"),
"players": [],
"capture_basis": "rcon-competitive-window",
"capabilities": item.get("capabilities"),
"match_url": None,
"match_url": resolve_rcon_scoreboard_match_url(
server_slug=server_slug,
map_name=item.get("map_pretty_name") or item.get("map_name"),
started_at=item["first_seen_at"],
ended_at=item["last_seen_at"],
duration_seconds=item.get("duration_seconds"),
player_count=player_count,
peak_players=item.get("peak_players"),
),
}

View File

@@ -0,0 +1,218 @@
"""Correlate RCON competitive windows with trusted persisted scoreboard matches."""
from __future__ import annotations
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from .config import get_storage_path
from .normalizers import normalize_map_name
from .scoreboard_origins import resolve_trusted_scoreboard_match_url
from .sqlite_utils import connect_sqlite_readonly
MIN_CONFIDENCE_SCORE = 5
MAX_CANDIDATES = 200
def resolve_rcon_scoreboard_match_url(
*,
server_slug: object,
map_name: object,
started_at: object,
ended_at: object,
duration_seconds: object = None,
player_count: object = None,
peak_players: object = None,
db_path: Path | None = None,
) -> str | None:
"""Return a trusted scoreboard URL for an RCON window only on strong evidence."""
normalized_server_slug = str(server_slug or "").strip()
normalized_map = normalize_map_name(map_name)
rcon_start = _parse_timestamp(started_at)
rcon_end = _parse_timestamp(ended_at)
if not normalized_server_slug or not normalized_map or not rcon_start or not rcon_end:
return None
if rcon_end < rcon_start:
rcon_start, rcon_end = rcon_end, rcon_start
candidates = _list_persisted_scoreboard_candidates(
server_slug=normalized_server_slug,
db_path=db_path or get_storage_path(),
)
scored_candidates = [
scored
for candidate in candidates
if (scored := _score_candidate(
candidate,
normalized_map=normalized_map,
rcon_start=rcon_start,
rcon_end=rcon_end,
duration_seconds=_coerce_int(duration_seconds),
player_count=_coerce_int(player_count),
peak_players=_coerce_int(peak_players),
))
is not None
]
if not scored_candidates:
return None
scored_candidates.sort(key=lambda item: item["score"], reverse=True)
best = scored_candidates[0]
if int(best["score"]) < MIN_CONFIDENCE_SCORE:
return None
if len(scored_candidates) > 1 and int(scored_candidates[1]["score"]) >= int(best["score"]):
return None
return str(best["match_url"])
def _list_persisted_scoreboard_candidates(
*,
server_slug: str,
db_path: Path,
) -> list[dict[str, object]]:
try:
with connect_sqlite_readonly(db_path) as connection:
rows = connection.execute(
"""
SELECT
historical_matches.external_match_id,
historical_matches.started_at,
historical_matches.ended_at,
historical_matches.map_name,
historical_matches.map_pretty_name,
historical_matches.raw_payload_ref,
historical_servers.slug AS server_slug,
COUNT(historical_player_match_stats.id) AS player_count
FROM historical_matches
INNER JOIN historical_servers
ON historical_servers.id = historical_matches.historical_server_id
LEFT JOIN historical_player_match_stats
ON historical_player_match_stats.historical_match_id = historical_matches.id
WHERE historical_servers.slug = ?
AND historical_matches.raw_payload_ref IS NOT NULL
GROUP BY historical_matches.id
ORDER BY COALESCE(historical_matches.ended_at, historical_matches.started_at) DESC
LIMIT ?
""",
(server_slug, MAX_CANDIDATES),
).fetchall()
except sqlite3.Error:
return []
items: list[dict[str, object]] = []
for row in rows:
match_url = resolve_trusted_scoreboard_match_url(
row["raw_payload_ref"],
row["server_slug"],
)
if not match_url:
continue
items.append(
{
"external_match_id": row["external_match_id"],
"started_at": row["started_at"],
"ended_at": row["ended_at"],
"map_name": row["map_name"],
"map_pretty_name": row["map_pretty_name"],
"player_count": row["player_count"],
"match_url": match_url,
}
)
return items
def _score_candidate(
candidate: dict[str, object],
*,
normalized_map: str,
rcon_start: datetime,
rcon_end: datetime,
duration_seconds: int | None,
player_count: int | None,
peak_players: int | None,
) -> dict[str, object] | None:
candidate_map = normalize_map_name(
candidate.get("map_pretty_name") or candidate.get("map_name")
)
if candidate_map != normalized_map:
return None
candidate_start = _parse_timestamp(candidate.get("started_at"))
candidate_end = _parse_timestamp(candidate.get("ended_at"))
if not candidate_start or not candidate_end:
return None
if candidate_end < candidate_start:
candidate_start, candidate_end = candidate_end, candidate_start
score = 0
overlap_seconds = _overlap_seconds(rcon_start, rcon_end, candidate_start, candidate_end)
rcon_midpoint = rcon_start + (rcon_end - rcon_start) / 2
if overlap_seconds > 0:
score += 3
if candidate_start <= rcon_midpoint <= candidate_end:
score += 2
closest_edge_distance = min(
abs((rcon_start - candidate_start).total_seconds()),
abs((rcon_start - candidate_end).total_seconds()),
abs((rcon_end - candidate_start).total_seconds()),
abs((rcon_end - candidate_end).total_seconds()),
)
if closest_edge_distance <= 1800:
score += 2
elif closest_edge_distance <= 3600:
score += 1
candidate_duration = int((candidate_end - candidate_start).total_seconds())
if duration_seconds and candidate_duration > 0:
if abs(candidate_duration - duration_seconds) <= 1800:
score += 1
elif overlap_seconds > 0 and duration_seconds <= candidate_duration:
score += 1
candidate_players = _coerce_int(candidate.get("player_count"))
reference_players = peak_players or player_count
if candidate_players and reference_players:
if abs(candidate_players - reference_players) <= 20:
score += 1
elif candidate_players >= int(reference_players * 0.75):
score += 1
if score <= 0:
return None
return {
"score": score,
"match_url": candidate["match_url"],
}
def _overlap_seconds(
first_start: datetime,
first_end: datetime,
second_start: datetime,
second_end: datetime,
) -> int:
return max(0, int((min(first_end, second_end) - max(first_start, second_start)).total_seconds()))
def _parse_timestamp(value: object) -> datetime | None:
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _coerce_int(value: object) -> int | None:
if value is None:
return None
try:
return int(round(float(value)))
except (TypeError, ValueError):
return None

View File

@@ -0,0 +1,76 @@
"""Trusted public scoreboard origins for active community servers."""
from __future__ import annotations
from dataclasses import dataclass
from urllib.parse import urlparse
@dataclass(frozen=True, slots=True)
class TrustedScoreboardOrigin:
"""Public scoreboard origin trusted for one active community server."""
slug: str
display_name: str
base_url: str
server_number: int
source_kind: str = "crcon-scoreboard-json"
TRUSTED_PUBLIC_SCOREBOARD_ORIGINS = (
TrustedScoreboardOrigin(
slug="comunidad-hispana-01",
display_name="Comunidad Hispana #01",
base_url="https://scoreboard.comunidadhll.es",
server_number=1,
),
TrustedScoreboardOrigin(
slug="comunidad-hispana-02",
display_name="Comunidad Hispana #02",
base_url="https://scoreboard.comunidadhll.es:5443",
server_number=2,
),
)
def list_trusted_public_scoreboard_origins() -> tuple[TrustedScoreboardOrigin, ...]:
"""Return trusted public scoreboard origins for active default servers."""
return TRUSTED_PUBLIC_SCOREBOARD_ORIGINS
def get_trusted_public_scoreboard_origin(
server_slug: object,
) -> TrustedScoreboardOrigin | None:
"""Return the trusted public scoreboard origin for one active server."""
normalized_slug = str(server_slug or "").strip()
if not normalized_slug:
return None
for origin in TRUSTED_PUBLIC_SCOREBOARD_ORIGINS:
if origin.slug == normalized_slug:
return origin
return None
def resolve_trusted_scoreboard_match_url(
raw_payload_ref: object,
server_slug: object,
) -> str | None:
"""Return a match URL only when it belongs to the trusted server origin."""
origin = get_trusted_public_scoreboard_origin(server_slug)
candidate = str(raw_payload_ref or "").strip()
if origin is None or not candidate:
return None
candidate_parts = urlparse(candidate)
origin_parts = urlparse(origin.base_url)
if candidate_parts.scheme not in {"http", "https"}:
return None
if candidate_parts.scheme != origin_parts.scheme:
return None
if candidate_parts.netloc != origin_parts.netloc:
return None
if candidate_parts.username or candidate_parts.password:
return None
if not candidate_parts.path.startswith("/games/"):
return None
return candidate

View File

@@ -0,0 +1,247 @@
"""Regression checks for persisted public-scoreboard match links."""
from __future__ import annotations
import gc
import os
import sqlite3
import tempfile
import unittest
from pathlib import Path
from app.historical_storage import (
get_historical_match_detail,
initialize_historical_storage,
list_recent_historical_matches,
upsert_historical_match,
)
from app.rcon_historical_storage import initialize_rcon_historical_storage
from app.rcon_historical_storage import persist_rcon_historical_sample
from app.rcon_historical_storage import start_rcon_historical_capture_run
from app.rcon_historical_read_model import get_rcon_historical_match_detail
class PersistedScoreboardMatchLinkTests(unittest.TestCase):
def test_recent_and_detail_payloads_expose_safe_persisted_match_url(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3"
match_url = "https://scoreboard.comunidadhll.es:5443/games/12345"
_persist_match(db_path, server_slug="comunidad-hispana-02", match_id="12345")
recent_items = list_recent_historical_matches(
server_slug="comunidad-hispana-02",
limit=5,
db_path=db_path,
)
detail = get_historical_match_detail(
server_slug="comunidad-hispana-02",
match_id="12345",
db_path=db_path,
)
self.assertEqual(recent_items[0]["match_url"], match_url)
self.assertIsNotNone(detail)
self.assertEqual(detail["match_url"], match_url)
gc.collect()
def test_untrusted_persisted_match_url_is_not_exposed(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3"
_persist_match(db_path, server_slug="comunidad-hispana-01", match_id="999")
_set_raw_payload_ref(
db_path,
match_id="999",
raw_payload_ref="https://scoreboard.comunidadhll.es:3443/games/999",
)
recent_items = list_recent_historical_matches(
server_slug="comunidad-hispana-01",
limit=5,
db_path=db_path,
)
detail = get_historical_match_detail(
server_slug="comunidad-hispana-01",
match_id="999",
db_path=db_path,
)
self.assertIsNone(recent_items[0]["match_url"])
self.assertIsNotNone(detail)
self.assertIsNone(detail["match_url"])
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"
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
try:
initialize_rcon_historical_storage(db_path=db_path)
detail = get_rcon_historical_match_detail(
server_key="comunidad-hispana-01",
match_id="rcon:synthetic-window",
)
finally:
if previous_storage_path is None:
os.environ.pop("HLL_BACKEND_STORAGE_PATH", None)
else:
os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path
self.assertIsNone(detail)
gc.collect()
def test_rcon_match_detail_exposes_correlated_scoreboard_url_on_strong_evidence(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3"
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
try:
_persist_match(
db_path,
server_slug="comunidad-hispana-01",
match_id="1561515",
map_name="St. Mere Eglise",
started_at="2026-04-12T16:20:00Z",
ended_at="2026-04-12T17:45:00Z",
)
session_key = _persist_rcon_window(
db_path,
map_name="St. Mere Eglise",
first_seen_at="2026-04-12T16:28:55.761810Z",
last_seen_at="2026-04-12T16:43:55.761810Z",
players=94,
max_players=98,
)
detail = get_rcon_historical_match_detail(
server_key="comunidad-hispana-01",
match_id=session_key,
)
finally:
if previous_storage_path is None:
os.environ.pop("HLL_BACKEND_STORAGE_PATH", None)
else:
os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path
self.assertIsNotNone(detail)
self.assertEqual(
detail["match_url"],
"https://scoreboard.comunidadhll.es/games/1561515",
)
gc.collect()
def test_rcon_match_detail_keeps_low_confidence_correlation_unlinked(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "historical.sqlite3"
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
try:
_persist_match(
db_path,
server_slug="comunidad-hispana-01",
match_id="1561515",
map_name="Carentan",
started_at="2026-04-12T10:00:00Z",
ended_at="2026-04-12T11:30:00Z",
)
session_key = _persist_rcon_window(
db_path,
map_name="St. Mere Eglise",
first_seen_at="2026-04-12T16:28:55.761810Z",
last_seen_at="2026-04-12T16:43:55.761810Z",
players=94,
max_players=98,
)
detail = get_rcon_historical_match_detail(
server_key="comunidad-hispana-01",
match_id=session_key,
)
finally:
if previous_storage_path is None:
os.environ.pop("HLL_BACKEND_STORAGE_PATH", None)
else:
os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path
self.assertIsNotNone(detail)
self.assertIsNone(detail["match_url"])
gc.collect()
def _persist_match(
db_path: Path,
*,
server_slug: str,
match_id: str,
map_name: str = "carentan",
started_at: str = "2026-05-01T10:00:00Z",
ended_at: str = "2026-05-01T11:20:00Z",
) -> None:
upsert_historical_match(
server_slug=server_slug,
match_payload={
"id": match_id,
"creation_time": started_at,
"start": started_at,
"end": ended_at,
"map": {"name": map_name},
"result": {"allied": 3, "axis": 2},
"player_stats": [],
},
db_path=db_path,
)
def _persist_rcon_window(
db_path: Path,
*,
map_name: str,
first_seen_at: str,
last_seen_at: str,
players: int,
max_players: int,
) -> str:
initialize_rcon_historical_storage(db_path=db_path)
run_id = start_rcon_historical_capture_run(
mode="test",
target_scope="comunidad-hispana-01",
db_path=db_path,
)
target = {
"target_key": "comunidad-hispana-01",
"external_server_id": "comunidad-hispana-01",
"name": "Comunidad Hispana #01",
"host": "127.0.0.1",
"port": 7779,
}
for captured_at in (first_seen_at, last_seen_at):
persist_rcon_historical_sample(
run_id=run_id,
captured_at=captured_at,
target=target,
normalized_payload={
"status": "online",
"players": players,
"max_players": max_players,
"current_map": map_name,
},
raw_payload={},
db_path=db_path,
)
return f"1:{first_seen_at}"
def _set_raw_payload_ref(db_path: Path, *, match_id: str, raw_payload_ref: str) -> None:
with sqlite3.connect(db_path) as connection:
connection.execute(
"""
UPDATE historical_matches
SET raw_payload_ref = ?
WHERE external_match_id = ?
""",
(raw_payload_ref, match_id),
)
if __name__ == "__main__":
unittest.main()