feat: add current match page and live kill feed
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_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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user