diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py index b32dca2..c479839 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -18,6 +18,14 @@ from .rcon_historical_storage import initialize_rcon_historical_storage from .sqlite_utils import connect_sqlite_writer 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: @@ -462,74 +470,220 @@ def list_current_match_player_stats( 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 + """Return current-match participants and partial stats from the safe AdminLog 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 - 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", - } + 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, + now=now, ) - items.sort( - key=lambda player: ( - -int(player["kills"]), - int(player["deaths"]), - str(player["player_name"]).casefold(), + rows = _list_current_match_participant_rows( + connection, + server_key=server_key, + window=window, ) - ) + + players: dict[str, dict[str, object]] = {} + for row in rows: + payload = _json_mapping(row["parsed_payload_json"]) + event_timestamp = row["event_timestamp"] + event_type = str(row["event_type"] or "") + if event_type == "kill": + killer = _ensure_current_match_player( + players, + player_name=payload.get("killer_name"), + player_id=payload.get("killer_id"), + team=payload.get("killer_team"), + event_timestamp=event_timestamp, + is_connected=None, + source=event_type, + ) + victim = _ensure_current_match_player( + players, + player_name=payload.get("victim_name"), + player_id=payload.get("victim_id"), + team=payload.get("victim_team"), + event_timestamp=event_timestamp, + is_connected=None, + source=event_type, + ) + if killer is not None: + weapon = _safe_event_field(payload.get("weapon")) or "UNKNOWN" + _player_weapon_counts(killer)[weapon] += 1 + if payload.get("killer_team") and payload.get("killer_team") == payload.get("victim_team"): + 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 payload.get("killer_team") and payload.get("killer_team") == payload.get("victim_team"): + victim["deaths_by_teamkill"] = int(victim["deaths_by_teamkill"]) + 1 + continue + + if event_type == "team_switch": + _ensure_current_match_player( + players, + player_name=payload.get("player_name"), + player_id=payload.get("player_id"), + team=payload.get("to_team"), + event_timestamp=event_timestamp, + is_connected=None, + source=event_type, + ) + continue + + if event_type == "connected": + _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=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 { - "scope": feed["scope"], - "confidence": "event-derived-partial" if items else feed["confidence"], - "source": "rcon-admin-log-kill-events", + "scope": window["scope"], + "confidence": window["confidence"] if items else window["confidence"], + "source": "rcon-admin-log-current-match-summary", "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"], + "stale_events_filtered": int(window["stale_events_filtered"]), "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( *, target_key: str, @@ -664,37 +818,112 @@ def _safe_event_field(value: object) -> str | None: def _ensure_current_match_player( players: dict[str, dict[str, object]], - player_name: object, *, + player_name: object, + player_id: object, team: object, event_timestamp: object, + is_connected: bool | None, + source: str, ) -> dict[str, object] | None: 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 player = players.setdefault( - safe_name, + key, { + "player_id": safe_id, "player_name": safe_name, "team": None, "kills": 0, "deaths": 0, "teamkills": 0, "deaths_by_teamkill": 0, + "is_connected": 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) if safe_team: player["team"] = safe_team + if is_connected is not None: + player["is_connected"] = is_connected 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 + player["_sources"].add(source) 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: if not weapons: return None diff --git a/backend/tests/test_current_match_payload.py b/backend/tests/test_current_match_payload.py index 6ad00c3..51b844d 100644 --- a/backend/tests/test_current_match_payload.py +++ b/backend/tests/test_current_match_payload.py @@ -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["confidence"] == "event-derived-partial" - assert stats["source"] == "rcon-admin-log-kill-events" + assert stats["confidence"] == "admin-log-boundary" + assert stats["source"] == "rcon-admin-log-current-match-summary" assert [item["player_name"] for item in stats["items"]] == ["Alpha", "Bravo", "Charlie"] assert stats["items"][0] == { "player_name": "Alpha", + "player_id": "steam-alpha", "team": "Allies", "kills": 1, "deaths": 1, "teamkills": 1, "deaths_by_teamkill": 0, + "is_connected": None, + "connected": None, "last_seen_at": "2026-05-21T10:03:00Z", "favorite_weapon": "M1 GARAND", - "source": "rcon-admin-log-kill-events", - "confidence": "event-derived-partial", + "source": "kill", + "confidence": "admin-log-boundary", } 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): db_path = tmp_path / "admin-log.sqlite3" persist_rcon_admin_log_entries( diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index bb12464..680ea74 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -838,6 +838,15 @@ 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 { min-width: 0; overflow: hidden; @@ -860,6 +869,16 @@ 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 { display: grid; grid-template-rows: 25px auto; @@ -913,7 +932,6 @@ white-space: nowrap; } -.current-match-killfeed__team-badge, .current-match-killfeed__teamkill { display: inline-flex; align-items: center; @@ -925,35 +943,17 @@ 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 { border: 1px solid rgba(210, 182, 118, 0.36); color: var(--accent-warm); } +.current-match-killfeed__team-badge { + min-width: auto; + padding-inline: 8px; + font-size: 0.62rem; +} + .current-match-player-intro { display: flex; align-items: flex-end; @@ -1261,6 +1261,10 @@ white-space: normal; } + .current-match-killfeed__player-identity { + gap: 5px; + } + .current-match-killfeed__teamkill { min-height: 16px; font-size: 0.54rem; @@ -1349,4 +1353,4 @@ The internal detail page can still show its own scoreboard button. */ #recent-matches-list .historical-match-card__link--scoreboard { display: none; -} \ No newline at end of file +} diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js index 26afd80..e1c01c2 100644 --- a/frontend/assets/js/partida-actual.js +++ b/frontend/assets/js/partida-actual.js @@ -479,11 +479,13 @@ function renderKillFeedRow(event) { data-event-id="${escapeHtml(event.event_id || "")}" > - - ${escapeHtml(event.killer_name || "Jugador no disponible")} - - + + + ${escapeHtml(event.killer_name || "Jugador no disponible")} + ${renderKillFeedTeamBadge(killerTeam)} + + ${teamkillBadge} @@ -496,10 +498,12 @@ function renderKillFeedRow(event) { ${escapeHtml(weapon.label)} - - ${escapeHtml(event.victim_name || "Objetivo no disponible")} + + + ${escapeHtml(event.victim_name || "Objetivo no disponible")} + + ${renderKillFeedTeamBadge(victimTeam)} - ${renderKillFeedTeamBadge(victimTeam)} `; @@ -511,8 +515,11 @@ function getKillFeedTeamDisplay(value) { } function renderKillFeedTeamBadge(team) { + if (!team || team.key === "unknown") { + return ""; + } return ` - + ${escapeHtml(team.label)} `;