Merge remote-tracking branch 'origin/codex/fix-rcon-detail-scoreboard-link'

This commit is contained in:
devRaGonSa
2026-05-20 13:03:49 +02:00
3 changed files with 120 additions and 6 deletions

View File

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

View File

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

View File

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