feat: add current match page and live kill feed

This commit is contained in:
devRaGonSa
2026-05-21 15:18:03 +02:00
parent 4544cb3c0a
commit da3f1643a2
12 changed files with 703 additions and 50 deletions

View File

@@ -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_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
@@ -237,6 +238,63 @@ def build_server_detail_history_payload(
}
def build_current_match_payload(*, server_slug: str) -> dict[str, object]:
"""Return the live page projection for one trusted active server."""
origin = get_trusted_public_scoreboard_origin(server_slug)
if origin is None:
raise ValueError("Unsupported current match server.")
server_payload = build_servers_payload()
server_data = server_payload["data"]
item = next(
(
candidate
for candidate in server_data.get("items", [])
if candidate.get("external_server_id") == origin.slug
),
None,
)
return {
"status": "ok",
"data": {
"found": item is not None,
"server_slug": origin.slug,
"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,
"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,
"players": item.get("players") if item else None,
"max_players": item.get("max_players") 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,
},
}
def build_current_match_kill_feed_payload(
*,
server_slug: str,
limit: int = 30,
) -> dict[str, object]:
"""Return normalized AdminLog kill rows for one trusted current-match page."""
origin = get_trusted_public_scoreboard_origin(server_slug)
if origin is None:
raise ValueError("Unsupported current match server.")
feed = list_current_match_kill_feed(server_key=origin.slug, limit=limit)
return {
"status": "ok",
"data": {
"server_slug": origin.slug,
"server_name": origin.display_name,
**feed,
},
}
def build_error_payload(message: str) -> dict[str, str]:
"""Return the shared error payload shape used by the backend bootstrap."""
return {

View File

@@ -6,6 +6,7 @@ import json
import re
import sqlite3
from collections.abc import Mapping
from contextlib import closing
from pathlib import Path
from .config import get_storage_path, use_postgres_rcon_storage
@@ -328,6 +329,80 @@ def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dic
return [dict(row) for row in rows]
def list_current_match_kill_feed(
*,
server_key: str,
limit: int = 30,
db_path: Path | 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)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(sqlite3.connect(resolved_path))
with connection_scope as connection:
if isinstance(connection, sqlite3.Connection):
connection.row_factory = sqlite3.Row
boundary = connection.execute(
"""
SELECT event_type, server_time
FROM rcon_admin_log_events
WHERE (target_key = ? OR external_server_id = ?)
AND event_type IN ('match_start', 'match_end')
AND server_time IS NOT NULL
ORDER BY server_time DESC, id DESC
LIMIT 1
""",
(server_key, server_key),
).fetchone()
open_start_time = (
boundary["server_time"]
if boundary is not None and boundary["event_type"] == "match_start"
else None
)
if open_start_time is None:
rows = connection.execute(
"""
SELECT id, target_key, external_server_id, event_timestamp, server_time,
parsed_payload_json
FROM rcon_admin_log_events
WHERE (target_key = ? OR external_server_id = ?)
AND event_type = 'kill'
ORDER BY server_time DESC, id DESC
LIMIT ?
""",
(server_key, server_key, limit),
).fetchall()
scope = "recent-admin-log-window"
confidence = "partial"
else:
rows = connection.execute(
"""
SELECT id, target_key, external_server_id, event_timestamp, server_time,
parsed_payload_json
FROM rcon_admin_log_events
WHERE (target_key = ? OR external_server_id = ?)
AND event_type = 'kill'
AND server_time >= ?
ORDER BY server_time DESC, id DESC
LIMIT ?
""",
(server_key, server_key, open_start_time, limit),
).fetchall()
scope = "open-admin-log-match-window"
confidence = "admin-log-boundary"
return {
"scope": scope,
"confidence": confidence,
"items": [_serialize_kill_feed_row(row) for row in rows],
}
def get_latest_rcon_player_profile_summaries(
*,
target_key: str,
@@ -422,6 +497,33 @@ def _json_mapping(raw_value: object) -> dict[str, object]:
return parsed if isinstance(parsed, dict) else {}
def _serialize_kill_feed_row(row: Mapping[str, object]) -> dict[str, object]:
payload = _json_mapping(row["parsed_payload_json"])
target_key = str(row["external_server_id"] or row["target_key"] or "unknown")
killer_team = _safe_event_field(payload.get("killer_team"))
victim_team = _safe_event_field(payload.get("victim_team"))
return {
"event_id": f"rcon-admin-log:{target_key}:{row['id']}",
"event_timestamp": row["event_timestamp"],
"server_time": row["server_time"],
"killer_name": _safe_event_field(payload.get("killer_name")),
"killer_team": killer_team,
"victim_name": _safe_event_field(payload.get("victim_name")),
"victim_team": victim_team,
"weapon": _safe_event_field(payload.get("weapon")),
"is_teamkill": bool(
killer_team
and killer_team != "None"
and killer_team == victim_team
),
}
def _safe_event_field(value: object) -> str | None:
normalized = str(value or "").strip()
return normalized or None
def _persist_rcon_admin_log_entries_postgres(
*,
target: Mapping[str, object],

View File

@@ -7,6 +7,8 @@ from urllib.parse import parse_qs, urlparse
from .payloads import (
build_community_payload,
build_current_match_kill_feed_payload,
build_current_match_payload,
build_discord_payload,
build_elo_mmr_leaderboard_payload,
build_elo_mmr_player_payload,
@@ -37,6 +39,7 @@ from .payloads import (
build_weekly_leaderboard_payload,
build_weekly_top_kills_payload,
)
from .scoreboard_origins import get_trusted_public_scoreboard_origin
GET_ROUTES = {
@@ -60,6 +63,28 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
return HTTPStatus.OK, build_server_history_payload(limit=limit)
if parsed.path == "/api/current-match":
server_slug = parse_qs(parsed.query).get("server", [None])[0]
if not server_slug:
return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
if get_trusted_public_scoreboard_origin(server_slug) is None:
return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported")
return HTTPStatus.OK, build_current_match_payload(server_slug=server_slug)
if parsed.path == "/api/current-match/kills":
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_slug = parse_qs(parsed.query).get("server", [None])[0]
if not server_slug:
return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
if get_trusted_public_scoreboard_origin(server_slug) is None:
return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported")
return HTTPStatus.OK, build_current_match_kill_feed_payload(
server_slug=server_slug,
limit=limit,
)
if parsed.path == "/api/historical/weekly-top-kills":
limit = _parse_limit(parsed.query)
if limit is None:

View File

@@ -4,6 +4,7 @@ import sqlite3
from app.rcon_admin_log_storage import (
initialize_rcon_admin_log_storage,
list_current_match_kill_feed,
list_rcon_admin_log_event_counts,
persist_rcon_admin_log_entries,
)
@@ -259,3 +260,52 @@ def test_list_rcon_admin_log_event_counts_groups_by_target_and_event_type(tmp_pa
},
}
gc.collect()
def test_current_match_kill_feed_prefers_open_match_window_and_normalizes_rows(tmp_path):
db_path = tmp_path / "admin_log.sqlite3"
persist_rcon_admin_log_entries(
target=TARGET,
entries=[
{
"timestamp": "2026-05-19T09:59:00Z",
"message": (
"[0:59 min (90)] KILL: Old Killer(Allies/steam-old) -> "
"Old Victim(Axis/steam-victim-old) with M1 GARAND"
),
},
{
"timestamp": "2026-05-19T10:00:00Z",
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
},
{
"timestamp": "2026-05-19T10:01:00Z",
"message": (
"[2:00 min (120)] KILL: Alpha(Allies/steam-alpha) -> "
"Bravo(Allies/steam-bravo) with GRENADE"
),
},
],
db_path=db_path,
)
feed = list_current_match_kill_feed(
server_key="test-rcon-target",
db_path=db_path,
)
assert feed["scope"] == "open-admin-log-match-window"
assert feed["confidence"] == "admin-log-boundary"
assert len(feed["items"]) == 1
assert feed["items"][0] == {
"event_id": "rcon-admin-log:test-rcon-target:3",
"event_timestamp": "2026-05-19T10:01:00Z",
"server_time": 120,
"killer_name": "Alpha",
"killer_team": "Allies",
"victim_name": "Bravo",
"victim_team": "Allies",
"weapon": "GRENADE",
"is_teamkill": True,
}
gc.collect()