From 94362e5f936138563c7c1718b6cdff09343319a3 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Wed, 20 May 2026 12:53:46 +0200 Subject: [PATCH] fix: resolve scoreboard link for rcon match detail --- backend/app/rcon_historical_read_model.py | 49 ++++++++++++++--- backend/app/rcon_scoreboard_correlation.py | 23 ++++++++ .../test_rcon_materialization_pipeline.py | 54 +++++++++++++++++++ 3 files changed, 120 insertions(+), 6 deletions(-) diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index abe17da..697014b 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from .historical_storage import ALL_SERVERS_SLUG from .normalizers import normalize_map_name @@ -207,6 +207,7 @@ def get_rcon_historical_match_detail( def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object]: server_slug = item.get("external_server_id") or item.get("target_key") timestamps = _build_materialized_timestamp_payload(item) + correlation_window = _build_materialized_scoreboard_correlation_window(item, timestamps) return { "server": { "slug": item.get("target_key"), @@ -246,9 +247,11 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object "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=timestamps["started_at"], - ended_at=timestamps["ended_at"], + started_at=correlation_window["started_at"], + ended_at=correlation_window["ended_at"], duration_seconds=_calculate_match_duration_seconds(item), + allied_score=item.get("allied_score"), + axis_score=item.get("axis_score"), ), "capabilities": describe_rcon_historical_read_model()["capabilities"], } @@ -345,6 +348,28 @@ def _build_materialized_timestamp_payload(item: dict[str, object]) -> dict[str, } +def _build_materialized_scoreboard_correlation_window( + item: dict[str, object], + timestamps: dict[str, object], +) -> dict[str, object]: + started_at = timestamps.get("started_at") + ended_at = timestamps.get("ended_at") + if started_at and ended_at: + return {"started_at": started_at, "ended_at": ended_at} + + closed_at = timestamps.get("closed_at") or item.get("ended_at") or item.get("started_at") + duration_seconds = _calculate_match_duration_seconds(item) + closed_point = _parse_datetime(closed_at) + if closed_point is None or not duration_seconds: + return {"started_at": started_at, "ended_at": ended_at} + + started_point = closed_point - timedelta(seconds=int(duration_seconds)) + return { + "started_at": started_point.isoformat().replace("+00:00", "Z"), + "ended_at": closed_point.isoformat().replace("+00:00", "Z"), + } + + def _merge_recent_items( primary_items: list[dict[str, object]], fallback_items: list[dict[str, object]], @@ -522,13 +547,25 @@ def _build_all_servers_summary(items: list[dict[str, object]]) -> dict[str, obje def _minutes_since_timestamp(timestamp: str | None) -> int | None: if not timestamp: return None - captured_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) - if captured_at.tzinfo is None: - captured_at = captured_at.replace(tzinfo=timezone.utc) + captured_at = _parse_datetime(timestamp) + if captured_at is None: + return None delta = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc) return max(0, int(delta.total_seconds() // 60)) +def _parse_datetime(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 _calculate_coverage_hours( first_sample_at: str | None, last_sample_at: str | None, diff --git a/backend/app/rcon_scoreboard_correlation.py b/backend/app/rcon_scoreboard_correlation.py index 5446793..fd170eb 100644 --- a/backend/app/rcon_scoreboard_correlation.py +++ b/backend/app/rcon_scoreboard_correlation.py @@ -25,6 +25,8 @@ def resolve_rcon_scoreboard_match_url( duration_seconds: object = None, player_count: object = None, peak_players: object = None, + allied_score: object = None, + axis_score: object = None, db_path: Path | None = None, ) -> str | None: """Return a trusted scoreboard URL for an RCON window only on strong evidence.""" @@ -52,6 +54,8 @@ def resolve_rcon_scoreboard_match_url( duration_seconds=_coerce_int(duration_seconds), player_count=_coerce_int(player_count), peak_players=_coerce_int(peak_players), + allied_score=_coerce_int(allied_score), + axis_score=_coerce_int(axis_score), )) is not None ] @@ -82,6 +86,8 @@ def _list_persisted_scoreboard_candidates( historical_matches.ended_at, historical_matches.map_name, historical_matches.map_pretty_name, + historical_matches.allied_score, + historical_matches.axis_score, historical_matches.raw_payload_ref, historical_servers.slug AS server_slug, COUNT(historical_player_match_stats.id) AS player_count @@ -116,6 +122,8 @@ def _list_persisted_scoreboard_candidates( "ended_at": row["ended_at"], "map_name": row["map_name"], "map_pretty_name": row["map_pretty_name"], + "allied_score": row["allied_score"], + "axis_score": row["axis_score"], "player_count": row["player_count"], "match_url": match_url, } @@ -132,6 +140,8 @@ def _score_candidate( duration_seconds: int | None, player_count: int | None, peak_players: int | None, + allied_score: int | None, + axis_score: int | None, ) -> dict[str, object] | None: candidate_map = normalize_map_name( candidate.get("map_pretty_name") or candidate.get("map_name") @@ -172,6 +182,19 @@ def _score_candidate( elif overlap_seconds > 0 and duration_seconds <= candidate_duration: score += 1 + candidate_allied_score = _coerce_int(candidate.get("allied_score")) + candidate_axis_score = _coerce_int(candidate.get("axis_score")) + if ( + allied_score is not None + and axis_score is not None + and candidate_allied_score is not None + and candidate_axis_score is not None + ): + if candidate_allied_score == allied_score and candidate_axis_score == axis_score: + score += 2 + elif sorted((candidate_allied_score, candidate_axis_score)) == sorted((allied_score, axis_score)): + score += 1 + candidate_players = _coerce_int(candidate.get("player_count")) reference_players = peak_players or player_count if candidate_players and reference_players: diff --git a/backend/tests/test_rcon_materialization_pipeline.py b/backend/tests/test_rcon_materialization_pipeline.py index 2aff075..9ec1143 100644 --- a/backend/tests/test_rcon_materialization_pipeline.py +++ b/backend/tests/test_rcon_materialization_pipeline.py @@ -117,6 +117,60 @@ class RconMaterializationPipelineTests(unittest.TestCase): self.assertEqual(detail["duration_seconds"], 5400) gc.collect() + def test_equal_timestamp_materialized_detail_uses_closed_at_window_for_scoreboard_link(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: + upsert_historical_match( + server_slug="comunidad-hispana-02", + match_payload={ + "id": "1779183861", + "creation_time": "2026-05-01T10:30:00Z", + "start": "2026-05-01T10:30:00Z", + "end": "2026-05-01T12:00:00Z", + "map": {"name": "ST MARIE DU MONT Warfare"}, + "result": {"allied": 5, "axis": 0}, + "player_stats": [], + }, + db_path=db_path, + ) + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-02", + "external_server_id": "comunidad-hispana-02", + }, + entries=[ + { + "timestamp": "2026-05-01T12:00:00Z", + "message": "[1 min (100)] MATCH START ST MARIE DU MONT Warfare", + }, + { + "timestamp": "2026-05-01T12:00:00Z", + "message": "[91 min (5500)] MATCH ENDED `ST MARIE DU MONT Warfare` ALLIED (5 - 0) AXIS", + }, + ], + db_path=db_path, + ) + materialize_rcon_admin_log(db_path=db_path) + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-02", + match_id="comunidad-hispana-02:100:5500:stmariedumontwarfare", + ) + finally: + _restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path) + + self.assertIsNotNone(detail) + self.assertIsNone(detail["started_at"]) + self.assertIsNone(detail["ended_at"]) + self.assertEqual(detail["duration_seconds"], 5400) + self.assertEqual( + detail["match_url"], + "https://scoreboard.comunidadhll.es:5443/games/1779183861", + ) + gc.collect() + def test_match_detail_adds_safe_profile_summary_when_snapshot_exists(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "historical.sqlite3"