Merge remote-tracking branch 'origin/codex/fix-rcon-detail-scoreboard-link'
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from .historical_storage import ALL_SERVERS_SLUG
|
from .historical_storage import ALL_SERVERS_SLUG
|
||||||
from .normalizers import normalize_map_name
|
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]:
|
def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object]:
|
||||||
server_slug = item.get("external_server_id") or item.get("target_key")
|
server_slug = item.get("external_server_id") or item.get("target_key")
|
||||||
timestamps = _build_materialized_timestamp_payload(item)
|
timestamps = _build_materialized_timestamp_payload(item)
|
||||||
|
correlation_window = _build_materialized_scoreboard_correlation_window(item, timestamps)
|
||||||
return {
|
return {
|
||||||
"server": {
|
"server": {
|
||||||
"slug": item.get("target_key"),
|
"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(
|
"match_url": resolve_rcon_scoreboard_match_url(
|
||||||
server_slug=server_slug,
|
server_slug=server_slug,
|
||||||
map_name=item.get("map_pretty_name") or item.get("map_name"),
|
map_name=item.get("map_pretty_name") or item.get("map_name"),
|
||||||
started_at=timestamps["started_at"],
|
started_at=correlation_window["started_at"],
|
||||||
ended_at=timestamps["ended_at"],
|
ended_at=correlation_window["ended_at"],
|
||||||
duration_seconds=_calculate_match_duration_seconds(item),
|
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"],
|
"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(
|
def _merge_recent_items(
|
||||||
primary_items: list[dict[str, object]],
|
primary_items: list[dict[str, object]],
|
||||||
fallback_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:
|
def _minutes_since_timestamp(timestamp: str | None) -> int | None:
|
||||||
if not timestamp:
|
if not timestamp:
|
||||||
return None
|
return None
|
||||||
captured_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
captured_at = _parse_datetime(timestamp)
|
||||||
if captured_at.tzinfo is None:
|
if captured_at is None:
|
||||||
captured_at = captured_at.replace(tzinfo=timezone.utc)
|
return None
|
||||||
delta = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc)
|
delta = datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc)
|
||||||
return max(0, int(delta.total_seconds() // 60))
|
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(
|
def _calculate_coverage_hours(
|
||||||
first_sample_at: str | None,
|
first_sample_at: str | None,
|
||||||
last_sample_at: str | None,
|
last_sample_at: str | None,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ def resolve_rcon_scoreboard_match_url(
|
|||||||
duration_seconds: object = None,
|
duration_seconds: object = None,
|
||||||
player_count: object = None,
|
player_count: object = None,
|
||||||
peak_players: object = None,
|
peak_players: object = None,
|
||||||
|
allied_score: object = None,
|
||||||
|
axis_score: object = None,
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Return a trusted scoreboard URL for an RCON window only on strong evidence."""
|
"""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),
|
duration_seconds=_coerce_int(duration_seconds),
|
||||||
player_count=_coerce_int(player_count),
|
player_count=_coerce_int(player_count),
|
||||||
peak_players=_coerce_int(peak_players),
|
peak_players=_coerce_int(peak_players),
|
||||||
|
allied_score=_coerce_int(allied_score),
|
||||||
|
axis_score=_coerce_int(axis_score),
|
||||||
))
|
))
|
||||||
is not None
|
is not None
|
||||||
]
|
]
|
||||||
@@ -82,6 +86,8 @@ def _list_persisted_scoreboard_candidates(
|
|||||||
historical_matches.ended_at,
|
historical_matches.ended_at,
|
||||||
historical_matches.map_name,
|
historical_matches.map_name,
|
||||||
historical_matches.map_pretty_name,
|
historical_matches.map_pretty_name,
|
||||||
|
historical_matches.allied_score,
|
||||||
|
historical_matches.axis_score,
|
||||||
historical_matches.raw_payload_ref,
|
historical_matches.raw_payload_ref,
|
||||||
historical_servers.slug AS server_slug,
|
historical_servers.slug AS server_slug,
|
||||||
COUNT(historical_player_match_stats.id) AS player_count
|
COUNT(historical_player_match_stats.id) AS player_count
|
||||||
@@ -116,6 +122,8 @@ def _list_persisted_scoreboard_candidates(
|
|||||||
"ended_at": row["ended_at"],
|
"ended_at": row["ended_at"],
|
||||||
"map_name": row["map_name"],
|
"map_name": row["map_name"],
|
||||||
"map_pretty_name": row["map_pretty_name"],
|
"map_pretty_name": row["map_pretty_name"],
|
||||||
|
"allied_score": row["allied_score"],
|
||||||
|
"axis_score": row["axis_score"],
|
||||||
"player_count": row["player_count"],
|
"player_count": row["player_count"],
|
||||||
"match_url": match_url,
|
"match_url": match_url,
|
||||||
}
|
}
|
||||||
@@ -132,6 +140,8 @@ def _score_candidate(
|
|||||||
duration_seconds: int | None,
|
duration_seconds: int | None,
|
||||||
player_count: int | None,
|
player_count: int | None,
|
||||||
peak_players: int | None,
|
peak_players: int | None,
|
||||||
|
allied_score: int | None,
|
||||||
|
axis_score: int | None,
|
||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
candidate_map = normalize_map_name(
|
candidate_map = normalize_map_name(
|
||||||
candidate.get("map_pretty_name") or candidate.get("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:
|
elif overlap_seconds > 0 and duration_seconds <= candidate_duration:
|
||||||
score += 1
|
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"))
|
candidate_players = _coerce_int(candidate.get("player_count"))
|
||||||
reference_players = peak_players or player_count
|
reference_players = peak_players or player_count
|
||||||
if candidate_players and reference_players:
|
if candidate_players and reference_players:
|
||||||
|
|||||||
@@ -117,6 +117,60 @@ class RconMaterializationPipelineTests(unittest.TestCase):
|
|||||||
self.assertEqual(detail["duration_seconds"], 5400)
|
self.assertEqual(detail["duration_seconds"], 5400)
|
||||||
gc.collect()
|
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:
|
def test_match_detail_adds_safe_profile_summary_when_snapshot_exists(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||||
|
|||||||
Reference in New Issue
Block a user