fix: resolve scoreboard link for rcon match detail
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user