feat: add current match killfeed overlay and player stats
This commit is contained in:
@@ -50,7 +50,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 .rcon_admin_log_storage import list_current_match_kill_feed, list_current_match_player_stats
|
||||
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
||||
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
||||
|
||||
@@ -370,6 +370,22 @@ def build_current_match_kill_feed_payload(
|
||||
}
|
||||
|
||||
|
||||
def build_current_match_player_stats_payload(*, server_slug: str) -> dict[str, object]:
|
||||
"""Return current player stats only when safe AdminLog evidence exists."""
|
||||
origin = get_trusted_public_scoreboard_origin(server_slug)
|
||||
if origin is None:
|
||||
raise ValueError("Unsupported current match server.")
|
||||
stats = list_current_match_player_stats(server_key=origin.slug)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"server_slug": origin.slug,
|
||||
"server_name": origin.display_name,
|
||||
**stats,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -422,6 +423,80 @@ def list_current_match_kill_feed(
|
||||
}
|
||||
|
||||
|
||||
def list_current_match_player_stats(
|
||||
*,
|
||||
server_key: str,
|
||||
db_path: Path | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return partial current player stats derived from safe AdminLog kill rows."""
|
||||
feed = list_current_match_kill_feed(
|
||||
server_key=server_key,
|
||||
limit=100,
|
||||
db_path=db_path,
|
||||
now=now,
|
||||
)
|
||||
players: dict[str, dict[str, object]] = {}
|
||||
weapon_counts: dict[str, Counter[str]] = {}
|
||||
for item in feed["items"]:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
killer = _ensure_current_match_player(
|
||||
players,
|
||||
item.get("killer_name"),
|
||||
team=item.get("killer_team"),
|
||||
event_timestamp=item.get("event_timestamp"),
|
||||
)
|
||||
victim = _ensure_current_match_player(
|
||||
players,
|
||||
item.get("victim_name"),
|
||||
team=item.get("victim_team"),
|
||||
event_timestamp=item.get("event_timestamp"),
|
||||
)
|
||||
if killer is not None:
|
||||
weapon = _safe_event_field(item.get("weapon")) or "UNKNOWN"
|
||||
weapon_counts.setdefault(str(killer["player_name"]), Counter())[weapon] += 1
|
||||
if item.get("is_teamkill"):
|
||||
killer["teamkills"] = int(killer["teamkills"]) + 1
|
||||
else:
|
||||
killer["kills"] = int(killer["kills"]) + 1
|
||||
if victim is not None:
|
||||
victim["deaths"] = int(victim["deaths"]) + 1
|
||||
if item.get("is_teamkill"):
|
||||
victim["deaths_by_teamkill"] = int(victim["deaths_by_teamkill"]) + 1
|
||||
|
||||
items = []
|
||||
for player in players.values():
|
||||
items.append(
|
||||
{
|
||||
**player,
|
||||
"favorite_weapon": _favorite_weapon_for_player(
|
||||
weapon_counts.get(str(player["player_name"]))
|
||||
),
|
||||
"source": "rcon-admin-log-kill-events",
|
||||
"confidence": "event-derived-partial",
|
||||
}
|
||||
)
|
||||
items.sort(
|
||||
key=lambda player: (
|
||||
-int(player["kills"]),
|
||||
int(player["deaths"]),
|
||||
str(player["player_name"]).casefold(),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"scope": feed["scope"],
|
||||
"confidence": "event-derived-partial" if items else feed["confidence"],
|
||||
"source": "rcon-admin-log-kill-events",
|
||||
"updated_at": max(
|
||||
(str(item["last_seen_at"]) for item in items if item.get("last_seen_at")),
|
||||
default=None,
|
||||
),
|
||||
"stale_events_filtered": feed["stale_events_filtered"],
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def get_latest_rcon_player_profile_summaries(
|
||||
*,
|
||||
target_key: str,
|
||||
@@ -543,6 +618,45 @@ def _safe_event_field(value: object) -> str | None:
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _ensure_current_match_player(
|
||||
players: dict[str, dict[str, object]],
|
||||
player_name: object,
|
||||
*,
|
||||
team: object,
|
||||
event_timestamp: object,
|
||||
) -> dict[str, object] | None:
|
||||
safe_name = _safe_event_field(player_name)
|
||||
if safe_name is None:
|
||||
return None
|
||||
player = players.setdefault(
|
||||
safe_name,
|
||||
{
|
||||
"player_name": safe_name,
|
||||
"team": None,
|
||||
"kills": 0,
|
||||
"deaths": 0,
|
||||
"teamkills": 0,
|
||||
"deaths_by_teamkill": 0,
|
||||
"last_seen_at": None,
|
||||
},
|
||||
)
|
||||
safe_team = _safe_event_field(team)
|
||||
if safe_team:
|
||||
player["team"] = safe_team
|
||||
safe_timestamp = _safe_event_field(event_timestamp)
|
||||
if safe_timestamp and (
|
||||
player["last_seen_at"] is None or safe_timestamp > str(player["last_seen_at"])
|
||||
):
|
||||
player["last_seen_at"] = safe_timestamp
|
||||
return player
|
||||
|
||||
|
||||
def _favorite_weapon_for_player(weapons: Counter[str] | None) -> str | None:
|
||||
if not weapons:
|
||||
return None
|
||||
return min(weapons.items(), key=lambda item: (-item[1], item[0]))[0]
|
||||
|
||||
|
||||
def _row_is_current_match_fallback_fresh(
|
||||
row: Mapping[str, object],
|
||||
freshness_anchor: datetime,
|
||||
|
||||
@@ -8,6 +8,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
from .payloads import (
|
||||
build_community_payload,
|
||||
build_current_match_kill_feed_payload,
|
||||
build_current_match_player_stats_payload,
|
||||
build_current_match_payload,
|
||||
build_discord_payload,
|
||||
build_elo_mmr_leaderboard_payload,
|
||||
@@ -85,6 +86,14 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if parsed.path == "/api/current-match/players":
|
||||
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_player_stats_payload(server_slug=server_slug)
|
||||
|
||||
if parsed.path == "/api/historical/weekly-top-kills":
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from http import HTTPStatus
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.payloads import build_current_match_payload
|
||||
from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries
|
||||
from app.rcon_client import RconServerTarget
|
||||
from app.routes import resolve_get_payload
|
||||
|
||||
@@ -109,6 +111,104 @@ def test_current_match_route_rejects_unsupported_server():
|
||||
assert payload["status"] == "error"
|
||||
|
||||
|
||||
def test_current_match_player_route_rejects_unsupported_server():
|
||||
status, payload = resolve_get_payload("/api/current-match/players?server=not-trusted")
|
||||
|
||||
assert status == HTTPStatus.NOT_FOUND
|
||||
assert payload["status"] == "error"
|
||||
|
||||
|
||||
def test_current_match_player_stats_aggregate_safe_admin_log_rows(tmp_path):
|
||||
db_path = tmp_path / "admin-log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:01:00Z",
|
||||
"message": (
|
||||
"[2:00 min (120)] KILL: Bravo(Axis/steam-bravo) -> "
|
||||
"Alpha(Allies/steam-alpha) with MP40"
|
||||
),
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:02:00Z",
|
||||
"message": (
|
||||
"[3:00 min (140)] KILL: Alpha(Allies/steam-alpha) -> "
|
||||
"Charlie(Allies/steam-charlie) with M1 GARAND"
|
||||
),
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:03:00Z",
|
||||
"message": (
|
||||
"[4:00 min (160)] KILL: Alpha(Allies/steam-alpha) -> "
|
||||
"Bravo(Axis/steam-bravo) with M1 GARAND"
|
||||
),
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
stats = list_current_match_player_stats(
|
||||
server_key="comunidad-hispana-01",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert stats["scope"] == "open-admin-log-match-window"
|
||||
assert stats["confidence"] == "event-derived-partial"
|
||||
assert stats["source"] == "rcon-admin-log-kill-events"
|
||||
assert [item["player_name"] for item in stats["items"]] == ["Alpha", "Bravo", "Charlie"]
|
||||
assert stats["items"][0] == {
|
||||
"player_name": "Alpha",
|
||||
"team": "Allies",
|
||||
"kills": 1,
|
||||
"deaths": 1,
|
||||
"teamkills": 1,
|
||||
"deaths_by_teamkill": 0,
|
||||
"last_seen_at": "2026-05-21T10:03:00Z",
|
||||
"favorite_weapon": "M1 GARAND",
|
||||
"source": "rcon-admin-log-kill-events",
|
||||
"confidence": "event-derived-partial",
|
||||
}
|
||||
assert "raw_message" not in stats["items"][0]
|
||||
|
||||
|
||||
def test_current_match_player_stats_filter_stale_recent_events(tmp_path):
|
||||
db_path = tmp_path / "admin-log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
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,
|
||||
)
|
||||
|
||||
stats = list_current_match_player_stats(
|
||||
server_key="comunidad-hispana-01",
|
||||
db_path=db_path,
|
||||
now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert stats["scope"] == "no-current-match-events"
|
||||
assert stats["confidence"] == "stale-filtered"
|
||||
assert stats["items"] == []
|
||||
|
||||
|
||||
def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
|
||||
with (
|
||||
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),
|
||||
|
||||
Reference in New Issue
Block a user