fix: project current match live data safely
This commit is contained in:
@@ -49,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 .rcon_client import load_rcon_targets, query_live_server_sample
|
||||
from .rcon_admin_log_storage import list_current_match_kill_feed
|
||||
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
||||
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
||||
@@ -244,6 +245,61 @@ def build_current_match_payload(*, server_slug: str) -> dict[str, object]:
|
||||
if origin is None:
|
||||
raise ValueError("Unsupported current match server.")
|
||||
|
||||
sample = _query_current_match_rcon_sample(origin.slug)
|
||||
if sample is not None:
|
||||
normalized = sample["normalized"]
|
||||
raw_session = sample["raw_session"]
|
||||
captured_at = _utc_timestamp_now()
|
||||
map_id = raw_session.get("mapId") or normalized.get("current_map")
|
||||
map_name = raw_session.get("mapName") or map_id
|
||||
map_pretty_name = normalize_map_name(map_name)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"found": True,
|
||||
"server_slug": origin.slug,
|
||||
"server_name": normalized.get("server_name") or origin.display_name,
|
||||
"status": normalized.get("status") or "unavailable",
|
||||
"map": map_pretty_name,
|
||||
"map_id": map_id,
|
||||
"map_pretty_name": map_pretty_name,
|
||||
"game_mode": normalized.get("game_mode"),
|
||||
"started_at": None,
|
||||
"allied_score": normalized.get("allied_score"),
|
||||
"axis_score": normalized.get("axis_score"),
|
||||
"allied_players": normalized.get("allied_players"),
|
||||
"axis_players": normalized.get("axis_players"),
|
||||
"players": normalized.get("players"),
|
||||
"max_players": normalized.get("max_players"),
|
||||
# RCA: getSession currently reports 0 while the public scoreboard
|
||||
# can show players, so session population is exposed but unverified.
|
||||
"player_count_quality": (
|
||||
"rcon-session-unverified"
|
||||
if normalized.get("players") is not None
|
||||
else None
|
||||
),
|
||||
"player_count_source": _source_when_present(
|
||||
normalized.get("players"),
|
||||
source="rcon-session",
|
||||
),
|
||||
"score_source": _source_when_present(
|
||||
normalized.get("allied_score"),
|
||||
normalized.get("axis_score"),
|
||||
source="rcon-session",
|
||||
),
|
||||
"map_source": _source_when_present(map_id, map_name, source="rcon-session"),
|
||||
"match_time_seconds": normalized.get("match_time_seconds"),
|
||||
"remaining_match_time_seconds": normalized.get(
|
||||
"remaining_match_time_seconds"
|
||||
),
|
||||
"captured_at": captured_at,
|
||||
"updated_at": captured_at,
|
||||
"public_scoreboard_url": origin.base_url,
|
||||
},
|
||||
}
|
||||
|
||||
# The generic live server snapshot is a fallback only. It intentionally
|
||||
# drops richer RCON session fields such as game mode and current scores.
|
||||
server_payload = build_servers_payload()
|
||||
server_data = server_payload["data"]
|
||||
item = next(
|
||||
@@ -262,12 +318,31 @@ def build_current_match_payload(*, server_slug: str) -> dict[str, object]:
|
||||
"server_name": item.get("server_name") if item else origin.display_name,
|
||||
"status": item.get("status") if item else "unavailable",
|
||||
"map": item.get("current_map") if item else None,
|
||||
"map_id": None,
|
||||
"map_pretty_name": item.get("current_map") if item else None,
|
||||
"game_mode": item.get("game_mode") if item else None,
|
||||
"started_at": item.get("started_at") if item else None,
|
||||
"allied_score": item.get("allied_score") if item else None,
|
||||
"axis_score": item.get("axis_score") if item else None,
|
||||
"allied_players": item.get("allied_players") if item else None,
|
||||
"axis_players": item.get("axis_players") if item else None,
|
||||
"players": item.get("players") if item else None,
|
||||
"max_players": item.get("max_players") if item else None,
|
||||
"player_count_quality": _snapshot_player_count_quality(item),
|
||||
"player_count_source": _snapshot_player_count_source(item),
|
||||
"score_source": _source_when_present(
|
||||
item.get("allied_score") if item else None,
|
||||
item.get("axis_score") if item else None,
|
||||
source="live-server-snapshot",
|
||||
),
|
||||
"map_source": _source_when_present(
|
||||
item.get("current_map") if item else None,
|
||||
source="live-server-snapshot",
|
||||
),
|
||||
"match_time_seconds": item.get("match_time_seconds") if item else None,
|
||||
"remaining_match_time_seconds": (
|
||||
item.get("remaining_match_time_seconds") if item else None
|
||||
),
|
||||
"captured_at": item.get("captured_at") if item else None,
|
||||
"updated_at": server_data.get("last_snapshot_at"),
|
||||
"public_scoreboard_url": origin.base_url,
|
||||
@@ -295,6 +370,52 @@ def build_current_match_kill_feed_payload(
|
||||
}
|
||||
|
||||
|
||||
def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None:
|
||||
"""Read one configured trusted RCON target for the current-match view."""
|
||||
try:
|
||||
targets = load_rcon_targets()
|
||||
except (RuntimeError, ValueError):
|
||||
return None
|
||||
target = next(
|
||||
(candidate for candidate in targets if candidate.external_server_id == server_slug),
|
||||
None,
|
||||
)
|
||||
if target is None:
|
||||
return None
|
||||
try:
|
||||
return query_live_server_sample(target)
|
||||
except Exception: # noqa: BLE001 - fall back to the existing live snapshot read
|
||||
return None
|
||||
|
||||
|
||||
def _utc_timestamp_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _source_when_present(*values: object, source: str) -> str | None:
|
||||
return source if any(value is not None for value in values) else None
|
||||
|
||||
|
||||
def _snapshot_player_count_quality(item: dict[str, object] | None) -> str | None:
|
||||
if item is None or item.get("players") is None:
|
||||
return None
|
||||
if item.get("snapshot_origin") == "real-rcon":
|
||||
return "rcon-session-unverified"
|
||||
if item.get("snapshot_origin") == "real-a2s":
|
||||
return "a2s-query"
|
||||
return "snapshot-unverified"
|
||||
|
||||
|
||||
def _snapshot_player_count_source(item: dict[str, object] | None) -> str | None:
|
||||
if item is None or item.get("players") is None:
|
||||
return None
|
||||
if item.get("snapshot_origin") == "real-rcon":
|
||||
return "rcon-session"
|
||||
if item.get("snapshot_origin") == "real-a2s":
|
||||
return "a2s"
|
||||
return "live-server-snapshot"
|
||||
|
||||
|
||||
def build_error_payload(message: str) -> dict[str, str]:
|
||||
"""Return the shared error payload shape used by the backend bootstrap."""
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import re
|
||||
import sqlite3
|
||||
from collections.abc import Mapping
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_storage_path, use_postgres_rcon_storage
|
||||
@@ -15,6 +16,8 @@ from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot
|
||||
from .rcon_historical_storage import initialize_rcon_historical_storage
|
||||
from .sqlite_utils import connect_sqlite_writer
|
||||
|
||||
CURRENT_MATCH_FALLBACK_FRESHNESS = timedelta(minutes=15)
|
||||
|
||||
|
||||
def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
|
||||
"""Create SQLite structures for parsed RCON AdminLog events."""
|
||||
@@ -334,6 +337,7 @@ def list_current_match_kill_feed(
|
||||
server_key: str,
|
||||
limit: int = 30,
|
||||
db_path: Path | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return safe recent kill rows for one AdminLog server window."""
|
||||
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
||||
@@ -396,9 +400,24 @@ def list_current_match_kill_feed(
|
||||
scope = "open-admin-log-match-window"
|
||||
confidence = "admin-log-boundary"
|
||||
|
||||
stale_events_filtered = 0
|
||||
if scope == "recent-admin-log-window":
|
||||
freshness_anchor = _as_utc_datetime(now) or datetime.now(timezone.utc)
|
||||
fresh_rows = [
|
||||
row
|
||||
for row in rows
|
||||
if _row_is_current_match_fallback_fresh(row, freshness_anchor)
|
||||
]
|
||||
stale_events_filtered = len(rows) - len(fresh_rows)
|
||||
rows = fresh_rows
|
||||
if not rows:
|
||||
scope = "no-current-match-events"
|
||||
confidence = "stale-filtered" if stale_events_filtered else "none"
|
||||
|
||||
return {
|
||||
"scope": scope,
|
||||
"confidence": confidence,
|
||||
"stale_events_filtered": stale_events_filtered,
|
||||
"items": [_serialize_kill_feed_row(row) for row in rows],
|
||||
}
|
||||
|
||||
@@ -524,6 +543,32 @@ def _safe_event_field(value: object) -> str | None:
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _row_is_current_match_fallback_fresh(
|
||||
row: Mapping[str, object],
|
||||
freshness_anchor: datetime,
|
||||
) -> bool:
|
||||
event_time = _as_utc_datetime(row["event_timestamp"])
|
||||
if event_time is None:
|
||||
return False
|
||||
age = freshness_anchor - event_time
|
||||
return timedelta(0) <= age <= CURRENT_MATCH_FALLBACK_FRESHNESS
|
||||
|
||||
|
||||
def _as_utc_datetime(value: object) -> datetime | None:
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
elif isinstance(value, str) and value.strip():
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _persist_rcon_admin_log_entries_postgres(
|
||||
*,
|
||||
target: Mapping[str, object],
|
||||
|
||||
118
backend/tests/test_current_match_payload.py
Normal file
118
backend/tests/test_current_match_payload.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.payloads import build_current_match_payload
|
||||
from app.rcon_client import RconServerTarget
|
||||
from app.routes import resolve_get_payload
|
||||
|
||||
|
||||
TARGET = RconServerTarget(
|
||||
name="Comunidad Hispana #01",
|
||||
host="127.0.0.1",
|
||||
port=7779,
|
||||
password="test-password",
|
||||
source_name="test-rcon",
|
||||
external_server_id="comunidad-hispana-01",
|
||||
)
|
||||
|
||||
|
||||
def test_current_match_payload_projects_rich_live_rcon_session_fields():
|
||||
data = _build_with_rcon_sample(
|
||||
{
|
||||
"normalized": {
|
||||
"server_name": "Comunidad Hispana #01",
|
||||
"status": "online",
|
||||
"current_map": "carentan_warfare",
|
||||
"game_mode": "Warfare",
|
||||
"allied_score": 2,
|
||||
"axis_score": 2,
|
||||
"allied_players": 0,
|
||||
"axis_players": 0,
|
||||
"players": 0,
|
||||
"max_players": 100,
|
||||
"match_time_seconds": 5400,
|
||||
"remaining_match_time_seconds": 0,
|
||||
},
|
||||
"raw_session": {"mapId": "carentan_warfare", "mapName": "CARENTAN"},
|
||||
}
|
||||
)
|
||||
|
||||
assert data["map"] == "Carentan"
|
||||
assert data["map_id"] == "carentan_warfare"
|
||||
assert data["map_pretty_name"] == "Carentan"
|
||||
assert data["game_mode"] == "Warfare"
|
||||
assert data["allied_score"] == 2
|
||||
assert data["axis_score"] == 2
|
||||
assert data["players"] == 0
|
||||
assert data["player_count_quality"] == "rcon-session-unverified"
|
||||
assert data["player_count_source"] == "rcon-session"
|
||||
assert data["score_source"] == "rcon-session"
|
||||
assert data["map_source"] == "rcon-session"
|
||||
assert data["public_scoreboard_url"] == "https://scoreboard.comunidadhll.es"
|
||||
assert "/games" not in data["public_scoreboard_url"]
|
||||
|
||||
|
||||
def test_current_match_payload_preserves_missing_values_as_null():
|
||||
data = _build_with_rcon_sample(
|
||||
{
|
||||
"normalized": {
|
||||
"server_name": "Comunidad Hispana #01",
|
||||
"status": "online",
|
||||
"current_map": None,
|
||||
"game_mode": None,
|
||||
"players": None,
|
||||
"max_players": None,
|
||||
},
|
||||
"raw_session": {},
|
||||
}
|
||||
)
|
||||
|
||||
assert data["map"] is None
|
||||
assert data["map_id"] is None
|
||||
assert data["game_mode"] is None
|
||||
assert data["allied_score"] is None
|
||||
assert data["axis_score"] is None
|
||||
assert data["players"] is None
|
||||
assert data["player_count_quality"] is None
|
||||
assert data["player_count_source"] is None
|
||||
assert data["score_source"] is None
|
||||
assert data["map_source"] is None
|
||||
|
||||
|
||||
def test_current_match_payload_keeps_explicit_zero_score():
|
||||
data = _build_with_rcon_sample(
|
||||
{
|
||||
"normalized": {
|
||||
"server_name": "Comunidad Hispana #01",
|
||||
"status": "online",
|
||||
"current_map": "stmariedumont_warfare",
|
||||
"allied_score": 0,
|
||||
"axis_score": 0,
|
||||
},
|
||||
"raw_session": {
|
||||
"mapId": "stmariedumont_warfare",
|
||||
"mapName": "ST MARIE DU MONT",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert data["map"] == "St. Marie Du Mont"
|
||||
assert data["allied_score"] == 0
|
||||
assert data["axis_score"] == 0
|
||||
assert data["score_source"] == "rcon-session"
|
||||
|
||||
|
||||
def test_current_match_route_rejects_unsupported_server():
|
||||
status, payload = resolve_get_payload("/api/current-match?server=not-trusted")
|
||||
|
||||
assert status == HTTPStatus.NOT_FOUND
|
||||
assert payload["status"] == "error"
|
||||
|
||||
|
||||
def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
|
||||
with (
|
||||
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),
|
||||
patch("app.payloads.query_live_server_sample", return_value=sample),
|
||||
):
|
||||
payload = build_current_match_payload(server_slug="comunidad-hispana-01")
|
||||
return payload["data"]
|
||||
@@ -1,6 +1,7 @@
|
||||
import gc
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.rcon_admin_log_storage import (
|
||||
initialize_rcon_admin_log_storage,
|
||||
@@ -309,3 +310,61 @@ def test_current_match_kill_feed_prefers_open_match_window_and_normalizes_rows(t
|
||||
"is_teamkill": True,
|
||||
}
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_current_match_kill_feed_filters_stale_recent_fallback_rows(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T09:30:00Z",
|
||||
"message": (
|
||||
"[1:00 min (1779355800)] KILL: Old Killer(Allies/steam-old) -> "
|
||||
"Old Victim(Axis/steam-victim-old) with M1 GARAND"
|
||||
),
|
||||
}
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
feed = list_current_match_kill_feed(
|
||||
server_key="test-rcon-target",
|
||||
db_path=db_path,
|
||||
now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert feed["scope"] == "no-current-match-events"
|
||||
assert feed["confidence"] == "stale-filtered"
|
||||
assert feed["stale_events_filtered"] == 1
|
||||
assert feed["items"] == []
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_current_match_kill_feed_marks_fresh_recent_fallback_rows_partial(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T09:50:00Z",
|
||||
"message": (
|
||||
"[1:00 min (1779357000)] KILL: Fresh Killer(Allies/steam-fresh) -> "
|
||||
"Fresh Victim(Axis/steam-victim-fresh) with M1 GARAND"
|
||||
),
|
||||
}
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
feed = list_current_match_kill_feed(
|
||||
server_key="test-rcon-target",
|
||||
db_path=db_path,
|
||||
now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert feed["scope"] == "recent-admin-log-window"
|
||||
assert feed["confidence"] == "partial"
|
||||
assert feed["stale_events_filtered"] == 0
|
||||
assert [item["killer_name"] for item in feed["items"]] == ["Fresh Killer"]
|
||||
gc.collect()
|
||||
|
||||
Reference in New Issue
Block a user