fix: show current match participants and align feed badges
This commit is contained in:
@@ -18,6 +18,14 @@ from .rcon_historical_storage import initialize_rcon_historical_storage
|
|||||||
from .sqlite_utils import connect_sqlite_writer
|
from .sqlite_utils import connect_sqlite_writer
|
||||||
|
|
||||||
CURRENT_MATCH_FALLBACK_FRESHNESS = timedelta(minutes=15)
|
CURRENT_MATCH_FALLBACK_FRESHNESS = timedelta(minutes=15)
|
||||||
|
CURRENT_MATCH_PLAYER_EVENT_TYPES = (
|
||||||
|
"kill",
|
||||||
|
"team_switch",
|
||||||
|
"connected",
|
||||||
|
"disconnected",
|
||||||
|
"chat",
|
||||||
|
"message",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
|
def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
|
||||||
@@ -462,74 +470,220 @@ def list_current_match_player_stats(
|
|||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return partial current player stats derived from safe AdminLog kill rows."""
|
"""Return current-match participants and partial stats from the safe AdminLog window."""
|
||||||
feed = list_current_match_kill_feed(
|
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
|
||||||
|
window = _resolve_current_match_window(
|
||||||
|
connection,
|
||||||
server_key=server_key,
|
server_key=server_key,
|
||||||
limit=100,
|
|
||||||
db_path=db_path,
|
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
|
rows = _list_current_match_participant_rows(
|
||||||
|
connection,
|
||||||
|
server_key=server_key,
|
||||||
|
window=window,
|
||||||
|
)
|
||||||
|
|
||||||
players: dict[str, dict[str, object]] = {}
|
players: dict[str, dict[str, object]] = {}
|
||||||
weapon_counts: dict[str, Counter[str]] = {}
|
for row in rows:
|
||||||
for item in feed["items"]:
|
payload = _json_mapping(row["parsed_payload_json"])
|
||||||
if not isinstance(item, Mapping):
|
event_timestamp = row["event_timestamp"]
|
||||||
continue
|
event_type = str(row["event_type"] or "")
|
||||||
|
if event_type == "kill":
|
||||||
killer = _ensure_current_match_player(
|
killer = _ensure_current_match_player(
|
||||||
players,
|
players,
|
||||||
item.get("killer_name"),
|
player_name=payload.get("killer_name"),
|
||||||
team=item.get("killer_team"),
|
player_id=payload.get("killer_id"),
|
||||||
event_timestamp=item.get("event_timestamp"),
|
team=payload.get("killer_team"),
|
||||||
|
event_timestamp=event_timestamp,
|
||||||
|
is_connected=None,
|
||||||
|
source=event_type,
|
||||||
)
|
)
|
||||||
victim = _ensure_current_match_player(
|
victim = _ensure_current_match_player(
|
||||||
players,
|
players,
|
||||||
item.get("victim_name"),
|
player_name=payload.get("victim_name"),
|
||||||
team=item.get("victim_team"),
|
player_id=payload.get("victim_id"),
|
||||||
event_timestamp=item.get("event_timestamp"),
|
team=payload.get("victim_team"),
|
||||||
|
event_timestamp=event_timestamp,
|
||||||
|
is_connected=None,
|
||||||
|
source=event_type,
|
||||||
)
|
)
|
||||||
if killer is not None:
|
if killer is not None:
|
||||||
weapon = _safe_event_field(item.get("weapon")) or "UNKNOWN"
|
weapon = _safe_event_field(payload.get("weapon")) or "UNKNOWN"
|
||||||
weapon_counts.setdefault(str(killer["player_name"]), Counter())[weapon] += 1
|
_player_weapon_counts(killer)[weapon] += 1
|
||||||
if item.get("is_teamkill"):
|
if payload.get("killer_team") and payload.get("killer_team") == payload.get("victim_team"):
|
||||||
killer["teamkills"] = int(killer["teamkills"]) + 1
|
killer["teamkills"] = int(killer["teamkills"]) + 1
|
||||||
else:
|
else:
|
||||||
killer["kills"] = int(killer["kills"]) + 1
|
killer["kills"] = int(killer["kills"]) + 1
|
||||||
if victim is not None:
|
if victim is not None:
|
||||||
victim["deaths"] = int(victim["deaths"]) + 1
|
victim["deaths"] = int(victim["deaths"]) + 1
|
||||||
if item.get("is_teamkill"):
|
if payload.get("killer_team") and payload.get("killer_team") == payload.get("victim_team"):
|
||||||
victim["deaths_by_teamkill"] = int(victim["deaths_by_teamkill"]) + 1
|
victim["deaths_by_teamkill"] = int(victim["deaths_by_teamkill"]) + 1
|
||||||
|
continue
|
||||||
|
|
||||||
items = []
|
if event_type == "team_switch":
|
||||||
for player in players.values():
|
_ensure_current_match_player(
|
||||||
items.append(
|
players,
|
||||||
{
|
player_name=payload.get("player_name"),
|
||||||
**player,
|
player_id=payload.get("player_id"),
|
||||||
"favorite_weapon": _favorite_weapon_for_player(
|
team=payload.get("to_team"),
|
||||||
weapon_counts.get(str(player["player_name"]))
|
event_timestamp=event_timestamp,
|
||||||
),
|
is_connected=None,
|
||||||
"source": "rcon-admin-log-kill-events",
|
source=event_type,
|
||||||
"confidence": "event-derived-partial",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
items.sort(
|
continue
|
||||||
key=lambda player: (
|
|
||||||
-int(player["kills"]),
|
if event_type == "connected":
|
||||||
int(player["deaths"]),
|
_ensure_current_match_player(
|
||||||
str(player["player_name"]).casefold(),
|
players,
|
||||||
|
player_name=payload.get("player_name"),
|
||||||
|
player_id=payload.get("player_id"),
|
||||||
|
team=None,
|
||||||
|
event_timestamp=event_timestamp,
|
||||||
|
is_connected=True,
|
||||||
|
source=event_type,
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type == "disconnected":
|
||||||
|
_ensure_current_match_player(
|
||||||
|
players,
|
||||||
|
player_name=payload.get("player_name"),
|
||||||
|
player_id=payload.get("player_id"),
|
||||||
|
team=None,
|
||||||
|
event_timestamp=event_timestamp,
|
||||||
|
is_connected=False,
|
||||||
|
source=event_type,
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type in {"chat", "message"}:
|
||||||
|
_ensure_current_match_player(
|
||||||
|
players,
|
||||||
|
player_name=payload.get("player_name"),
|
||||||
|
player_id=payload.get("player_id"),
|
||||||
|
team=payload.get("chat_team"),
|
||||||
|
event_timestamp=event_timestamp,
|
||||||
|
is_connected=None,
|
||||||
|
source=event_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
items = [_serialize_current_match_player(player, window_confidence=window["confidence"]) for player in players.values()]
|
||||||
|
items.sort(key=_current_match_player_sort_key)
|
||||||
return {
|
return {
|
||||||
"scope": feed["scope"],
|
"scope": window["scope"],
|
||||||
"confidence": "event-derived-partial" if items else feed["confidence"],
|
"confidence": window["confidence"] if items else window["confidence"],
|
||||||
"source": "rcon-admin-log-kill-events",
|
"source": "rcon-admin-log-current-match-summary",
|
||||||
"updated_at": max(
|
"updated_at": max(
|
||||||
(str(item["last_seen_at"]) for item in items if item.get("last_seen_at")),
|
(str(item["last_seen_at"]) for item in items if item.get("last_seen_at")),
|
||||||
default=None,
|
default=None,
|
||||||
),
|
),
|
||||||
"stale_events_filtered": feed["stale_events_filtered"],
|
"stale_events_filtered": int(window["stale_events_filtered"]),
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_current_match_window(
|
||||||
|
connection: sqlite3.Connection | object,
|
||||||
|
*,
|
||||||
|
server_key: str,
|
||||||
|
now: datetime | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
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 not None:
|
||||||
|
return {
|
||||||
|
"scope": "open-admin-log-match-window",
|
||||||
|
"confidence": "admin-log-boundary",
|
||||||
|
"open_start_time": int(open_start_time),
|
||||||
|
"stale_events_filtered": 0,
|
||||||
|
"freshness_anchor": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
freshness_anchor = _as_utc_datetime(now) or datetime.now(timezone.utc)
|
||||||
|
return {
|
||||||
|
"scope": "recent-admin-log-window",
|
||||||
|
"confidence": "partial",
|
||||||
|
"open_start_time": None,
|
||||||
|
"stale_events_filtered": 0,
|
||||||
|
"freshness_anchor": freshness_anchor,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_current_match_participant_rows(
|
||||||
|
connection: sqlite3.Connection | object,
|
||||||
|
*,
|
||||||
|
server_key: str,
|
||||||
|
window: Mapping[str, object],
|
||||||
|
) -> list[Mapping[str, object]]:
|
||||||
|
event_placeholders = ",".join("?" for _ in CURRENT_MATCH_PLAYER_EVENT_TYPES)
|
||||||
|
params: list[object] = [server_key, server_key, *CURRENT_MATCH_PLAYER_EVENT_TYPES]
|
||||||
|
if window.get("open_start_time") is not None:
|
||||||
|
params.append(window["open_start_time"])
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, target_key, external_server_id, event_timestamp, server_time, event_type,
|
||||||
|
parsed_payload_json
|
||||||
|
FROM rcon_admin_log_events
|
||||||
|
WHERE (target_key = ? OR external_server_id = ?)
|
||||||
|
AND event_type IN ({event_placeholders})
|
||||||
|
AND server_time >= ?
|
||||||
|
ORDER BY server_time ASC, id ASC
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, target_key, external_server_id, event_timestamp, server_time, event_type,
|
||||||
|
parsed_payload_json
|
||||||
|
FROM rcon_admin_log_events
|
||||||
|
WHERE (target_key = ? OR external_server_id = ?)
|
||||||
|
AND event_type IN ({event_placeholders})
|
||||||
|
ORDER BY server_time DESC, id DESC
|
||||||
|
LIMIT 500
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
freshness_anchor = window.get("freshness_anchor")
|
||||||
|
fresh_rows = [
|
||||||
|
row
|
||||||
|
for row in rows
|
||||||
|
if _row_is_current_match_fallback_fresh(row, freshness_anchor)
|
||||||
|
]
|
||||||
|
window["stale_events_filtered"] = len(rows) - len(fresh_rows)
|
||||||
|
if not fresh_rows:
|
||||||
|
window["scope"] = "no-current-match-events"
|
||||||
|
window["confidence"] = "stale-filtered" if window["stale_events_filtered"] else "none"
|
||||||
|
return []
|
||||||
|
return list(reversed(fresh_rows))
|
||||||
|
|
||||||
|
|
||||||
def get_latest_rcon_player_profile_summaries(
|
def get_latest_rcon_player_profile_summaries(
|
||||||
*,
|
*,
|
||||||
target_key: str,
|
target_key: str,
|
||||||
@@ -664,37 +818,112 @@ def _safe_event_field(value: object) -> str | None:
|
|||||||
|
|
||||||
def _ensure_current_match_player(
|
def _ensure_current_match_player(
|
||||||
players: dict[str, dict[str, object]],
|
players: dict[str, dict[str, object]],
|
||||||
player_name: object,
|
|
||||||
*,
|
*,
|
||||||
|
player_name: object,
|
||||||
|
player_id: object,
|
||||||
team: object,
|
team: object,
|
||||||
event_timestamp: object,
|
event_timestamp: object,
|
||||||
|
is_connected: bool | None,
|
||||||
|
source: str,
|
||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
safe_name = _safe_event_field(player_name)
|
safe_name = _safe_event_field(player_name)
|
||||||
if safe_name is None:
|
safe_id = _safe_event_field(player_id)
|
||||||
|
key = _current_match_player_key(safe_id, safe_name)
|
||||||
|
if key is None:
|
||||||
return None
|
return None
|
||||||
player = players.setdefault(
|
player = players.setdefault(
|
||||||
safe_name,
|
key,
|
||||||
{
|
{
|
||||||
|
"player_id": safe_id,
|
||||||
"player_name": safe_name,
|
"player_name": safe_name,
|
||||||
"team": None,
|
"team": None,
|
||||||
"kills": 0,
|
"kills": 0,
|
||||||
"deaths": 0,
|
"deaths": 0,
|
||||||
"teamkills": 0,
|
"teamkills": 0,
|
||||||
"deaths_by_teamkill": 0,
|
"deaths_by_teamkill": 0,
|
||||||
|
"is_connected": None,
|
||||||
"last_seen_at": None,
|
"last_seen_at": None,
|
||||||
|
"_weapon_counts": Counter(),
|
||||||
|
"_sources": set(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if safe_id:
|
||||||
|
player["player_id"] = safe_id
|
||||||
|
if safe_name:
|
||||||
|
current_name = _safe_event_field(player.get("player_name"))
|
||||||
|
if current_name is None or len(safe_name) >= len(current_name):
|
||||||
|
player["player_name"] = safe_name
|
||||||
safe_team = _safe_event_field(team)
|
safe_team = _safe_event_field(team)
|
||||||
if safe_team:
|
if safe_team:
|
||||||
player["team"] = safe_team
|
player["team"] = safe_team
|
||||||
|
if is_connected is not None:
|
||||||
|
player["is_connected"] = is_connected
|
||||||
safe_timestamp = _safe_event_field(event_timestamp)
|
safe_timestamp = _safe_event_field(event_timestamp)
|
||||||
if safe_timestamp and (
|
if safe_timestamp and (
|
||||||
player["last_seen_at"] is None or safe_timestamp > str(player["last_seen_at"])
|
player["last_seen_at"] is None or safe_timestamp > str(player["last_seen_at"])
|
||||||
):
|
):
|
||||||
player["last_seen_at"] = safe_timestamp
|
player["last_seen_at"] = safe_timestamp
|
||||||
|
player["_sources"].add(source)
|
||||||
return player
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
def _current_match_player_key(
|
||||||
|
player_id: str | None,
|
||||||
|
player_name: str | None,
|
||||||
|
) -> str | None:
|
||||||
|
if player_id:
|
||||||
|
return f"id:{player_id}"
|
||||||
|
if player_name:
|
||||||
|
return f"name:{player_name.casefold()}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _player_weapon_counts(player: Mapping[str, object]) -> Counter[str]:
|
||||||
|
weapon_counts = player.get("_weapon_counts")
|
||||||
|
if isinstance(weapon_counts, Counter):
|
||||||
|
return weapon_counts
|
||||||
|
return Counter()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_current_match_player(
|
||||||
|
player: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
window_confidence: object,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
sources = sorted(str(value) for value in player.get("_sources", set()))
|
||||||
|
return {
|
||||||
|
"player_name": player.get("player_name"),
|
||||||
|
"player_id": player.get("player_id"),
|
||||||
|
"team": player.get("team"),
|
||||||
|
"kills": int(player.get("kills") or 0),
|
||||||
|
"deaths": int(player.get("deaths") or 0),
|
||||||
|
"teamkills": int(player.get("teamkills") or 0),
|
||||||
|
"deaths_by_teamkill": int(player.get("deaths_by_teamkill") or 0),
|
||||||
|
"favorite_weapon": _favorite_weapon_for_player(_player_weapon_counts(player)),
|
||||||
|
"last_seen_at": player.get("last_seen_at"),
|
||||||
|
"is_connected": player.get("is_connected"),
|
||||||
|
"connected": player.get("is_connected"),
|
||||||
|
"source": ",".join(sources) if sources else "unknown",
|
||||||
|
"confidence": str(window_confidence or "partial"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_match_player_sort_key(player: Mapping[str, object]) -> tuple[int, int, int, str]:
|
||||||
|
connected = player.get("is_connected")
|
||||||
|
if connected is True:
|
||||||
|
connected_rank = 0
|
||||||
|
elif connected is None:
|
||||||
|
connected_rank = 1
|
||||||
|
else:
|
||||||
|
connected_rank = 2
|
||||||
|
return (
|
||||||
|
-int(player.get("kills") or 0),
|
||||||
|
int(player.get("deaths") or 0),
|
||||||
|
connected_rank,
|
||||||
|
str(player.get("player_name") or "").casefold(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _favorite_weapon_for_player(weapons: Counter[str] | None) -> str | None:
|
def _favorite_weapon_for_player(weapons: Counter[str] | None) -> str | None:
|
||||||
if not weapons:
|
if not weapons:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -245,24 +245,225 @@ def test_current_match_player_stats_aggregate_safe_admin_log_rows(tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert stats["scope"] == "open-admin-log-match-window"
|
assert stats["scope"] == "open-admin-log-match-window"
|
||||||
assert stats["confidence"] == "event-derived-partial"
|
assert stats["confidence"] == "admin-log-boundary"
|
||||||
assert stats["source"] == "rcon-admin-log-kill-events"
|
assert stats["source"] == "rcon-admin-log-current-match-summary"
|
||||||
assert [item["player_name"] for item in stats["items"]] == ["Alpha", "Bravo", "Charlie"]
|
assert [item["player_name"] for item in stats["items"]] == ["Alpha", "Bravo", "Charlie"]
|
||||||
assert stats["items"][0] == {
|
assert stats["items"][0] == {
|
||||||
"player_name": "Alpha",
|
"player_name": "Alpha",
|
||||||
|
"player_id": "steam-alpha",
|
||||||
"team": "Allies",
|
"team": "Allies",
|
||||||
"kills": 1,
|
"kills": 1,
|
||||||
"deaths": 1,
|
"deaths": 1,
|
||||||
"teamkills": 1,
|
"teamkills": 1,
|
||||||
"deaths_by_teamkill": 0,
|
"deaths_by_teamkill": 0,
|
||||||
|
"is_connected": None,
|
||||||
|
"connected": None,
|
||||||
"last_seen_at": "2026-05-21T10:03:00Z",
|
"last_seen_at": "2026-05-21T10:03:00Z",
|
||||||
"favorite_weapon": "M1 GARAND",
|
"favorite_weapon": "M1 GARAND",
|
||||||
"source": "rcon-admin-log-kill-events",
|
"source": "kill",
|
||||||
"confidence": "event-derived-partial",
|
"confidence": "admin-log-boundary",
|
||||||
}
|
}
|
||||||
assert "raw_message" not in stats["items"][0]
|
assert "raw_message" not in stats["items"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_player_stats_include_connected_players_without_kills(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)] CONNECTED Quiet Player (steam-quiet)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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["items"] == [
|
||||||
|
{
|
||||||
|
"player_name": "Quiet Player",
|
||||||
|
"player_id": "steam-quiet",
|
||||||
|
"team": None,
|
||||||
|
"kills": 0,
|
||||||
|
"deaths": 0,
|
||||||
|
"teamkills": 0,
|
||||||
|
"deaths_by_teamkill": 0,
|
||||||
|
"favorite_weapon": None,
|
||||||
|
"last_seen_at": "2026-05-21T10:01:00Z",
|
||||||
|
"is_connected": True,
|
||||||
|
"connected": True,
|
||||||
|
"source": "connected",
|
||||||
|
"confidence": "admin-log-boundary",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_player_stats_keep_disconnected_participants_visible(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)] CONNECTED Brief Player (steam-brief)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T10:05:00Z",
|
||||||
|
"message": "[6:00 min (180)] DISCONNECTED Brief Player (steam-brief)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = list_current_match_player_stats(
|
||||||
|
server_key="comunidad-hispana-01",
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stats["items"] == [
|
||||||
|
{
|
||||||
|
"player_name": "Brief Player",
|
||||||
|
"player_id": "steam-brief",
|
||||||
|
"team": None,
|
||||||
|
"kills": 0,
|
||||||
|
"deaths": 0,
|
||||||
|
"teamkills": 0,
|
||||||
|
"deaths_by_teamkill": 0,
|
||||||
|
"favorite_weapon": None,
|
||||||
|
"last_seen_at": "2026-05-21T10:05:00Z",
|
||||||
|
"is_connected": False,
|
||||||
|
"connected": False,
|
||||||
|
"source": "connected,disconnected",
|
||||||
|
"confidence": "admin-log-boundary",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_player_stats_include_victim_only_players(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: Killer One(Axis/steam-killer) -> "
|
||||||
|
"Victim Only(Allies/steam-victim) with MP40"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = list_current_match_player_stats(
|
||||||
|
server_key="comunidad-hispana-01",
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
by_name = {item["player_name"]: item for item in stats["items"]}
|
||||||
|
|
||||||
|
assert by_name["Victim Only"]["kills"] == 0
|
||||||
|
assert by_name["Victim Only"]["deaths"] == 1
|
||||||
|
assert by_name["Victim Only"]["favorite_weapon"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_player_stats_exclude_players_before_open_match_start(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:55:00Z",
|
||||||
|
"message": "[0:30 min (90)] CONNECTED Old Match Player (steam-old)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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)] CONNECTED New Match Player (steam-new)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = list_current_match_player_stats(
|
||||||
|
server_key="comunidad-hispana-01",
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [item["player_name"] for item in stats["items"]] == ["New Match Player"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_player_stats_sort_connected_before_disconnected_with_same_stats(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)] CONNECTED Connected Alpha (steam-connected)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T10:02:00Z",
|
||||||
|
"message": "[3:00 min (140)] CONNECTED Disconnected Bravo (steam-disconnected)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T10:03:00Z",
|
||||||
|
"message": "[4:00 min (160)] DISCONNECTED Disconnected Bravo (steam-disconnected)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = list_current_match_player_stats(
|
||||||
|
server_key="comunidad-hispana-01",
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [item["player_name"] for item in stats["items"]] == [
|
||||||
|
"Connected Alpha",
|
||||||
|
"Disconnected Bravo",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_current_match_player_stats_filter_stale_recent_events(tmp_path):
|
def test_current_match_player_stats_filter_stale_recent_events(tmp_path):
|
||||||
db_path = tmp_path / "admin-log.sqlite3"
|
db_path = tmp_path / "admin-log.sqlite3"
|
||||||
persist_rcon_admin_log_entries(
|
persist_rcon_admin_log_entries(
|
||||||
|
|||||||
@@ -838,6 +838,15 @@
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__player-identity {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.current-match-killfeed__player-name {
|
.current-match-killfeed__player-name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -860,6 +869,16 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__player--killer .current-match-killfeed__player-identity,
|
||||||
|
.current-match-killfeed__player--killer .current-match-killfeed__player-meta {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__player--victim .current-match-killfeed__player-identity,
|
||||||
|
.current-match-killfeed__player--victim .current-match-killfeed__player-meta {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.current-match-killfeed__weapon {
|
.current-match-killfeed__weapon {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 25px auto;
|
grid-template-rows: 25px auto;
|
||||||
@@ -913,7 +932,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-match-killfeed__team-badge,
|
|
||||||
.current-match-killfeed__teamkill {
|
.current-match-killfeed__teamkill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -925,35 +943,17 @@
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-match-killfeed__team-badge {
|
|
||||||
border: 1px solid rgba(159, 168, 141, 0.2);
|
|
||||||
color: var(--text-soft);
|
|
||||||
background: rgba(159, 168, 141, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-match-killfeed__team-badge--allies {
|
|
||||||
border-color: rgba(100, 139, 178, 0.34);
|
|
||||||
color: rgba(192, 215, 238, 0.96);
|
|
||||||
background: rgba(67, 101, 137, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-match-killfeed__team-badge--axis {
|
|
||||||
border-color: rgba(167, 109, 96, 0.36);
|
|
||||||
color: rgba(231, 198, 190, 0.96);
|
|
||||||
background: rgba(116, 68, 58, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-match-killfeed__team-badge--unknown {
|
|
||||||
border-color: rgba(159, 168, 141, 0.22);
|
|
||||||
color: var(--muted);
|
|
||||||
background: rgba(159, 168, 141, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-match-killfeed__teamkill {
|
.current-match-killfeed__teamkill {
|
||||||
border: 1px solid rgba(210, 182, 118, 0.36);
|
border: 1px solid rgba(210, 182, 118, 0.36);
|
||||||
color: var(--accent-warm);
|
color: var(--accent-warm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__team-badge {
|
||||||
|
min-width: auto;
|
||||||
|
padding-inline: 8px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
.current-match-player-intro {
|
.current-match-player-intro {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -1261,6 +1261,10 @@
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__player-identity {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.current-match-killfeed__teamkill {
|
.current-match-killfeed__teamkill {
|
||||||
min-height: 16px;
|
min-height: 16px;
|
||||||
font-size: 0.54rem;
|
font-size: 0.54rem;
|
||||||
|
|||||||
@@ -479,11 +479,13 @@ function renderKillFeedRow(event) {
|
|||||||
data-event-id="${escapeHtml(event.event_id || "")}"
|
data-event-id="${escapeHtml(event.event_id || "")}"
|
||||||
>
|
>
|
||||||
<span class="current-match-killfeed__player current-match-killfeed__player--killer">
|
<span class="current-match-killfeed__player current-match-killfeed__player--killer">
|
||||||
|
<span class="current-match-killfeed__player-identity">
|
||||||
<strong class="current-match-killfeed__player-name" title="${escapeHtml(event.killer_name || "Jugador no disponible")}">
|
<strong class="current-match-killfeed__player-name" title="${escapeHtml(event.killer_name || "Jugador no disponible")}">
|
||||||
${escapeHtml(event.killer_name || "Jugador no disponible")}
|
${escapeHtml(event.killer_name || "Jugador no disponible")}
|
||||||
</strong>
|
</strong>
|
||||||
<span class="current-match-killfeed__player-meta">
|
|
||||||
${renderKillFeedTeamBadge(killerTeam)}
|
${renderKillFeedTeamBadge(killerTeam)}
|
||||||
|
</span>
|
||||||
|
<span class="current-match-killfeed__player-meta">
|
||||||
${teamkillBadge}
|
${teamkillBadge}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -496,11 +498,13 @@ function renderKillFeedRow(event) {
|
|||||||
<em>${escapeHtml(weapon.label)}</em>
|
<em>${escapeHtml(weapon.label)}</em>
|
||||||
</span>
|
</span>
|
||||||
<span class="current-match-killfeed__player current-match-killfeed__player--victim">
|
<span class="current-match-killfeed__player current-match-killfeed__player--victim">
|
||||||
|
<span class="current-match-killfeed__player-identity">
|
||||||
<span class="current-match-killfeed__player-name" title="${escapeHtml(event.victim_name || "Objetivo no disponible")}">
|
<span class="current-match-killfeed__player-name" title="${escapeHtml(event.victim_name || "Objetivo no disponible")}">
|
||||||
${escapeHtml(event.victim_name || "Objetivo no disponible")}
|
${escapeHtml(event.victim_name || "Objetivo no disponible")}
|
||||||
</span>
|
</span>
|
||||||
${renderKillFeedTeamBadge(victimTeam)}
|
${renderKillFeedTeamBadge(victimTeam)}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -511,8 +515,11 @@ function getKillFeedTeamDisplay(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderKillFeedTeamBadge(team) {
|
function renderKillFeedTeamBadge(team) {
|
||||||
|
if (!team || team.key === "unknown") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
return `
|
return `
|
||||||
<span class="current-match-killfeed__team-badge current-match-killfeed__team-badge--${team.key}">
|
<span class="historical-player-team-badge historical-player-team-badge--${team.key} current-match-killfeed__team-badge">
|
||||||
${escapeHtml(team.label)}
|
${escapeHtml(team.label)}
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|||||||
Reference in New Issue
Block a user