From e389abfafe1a45d47b21dc67aba992de1d35868f Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Wed, 17 Jun 2026 15:27:15 +0200 Subject: [PATCH] Dedupe current match live player stats --- ...-dedupe-current-match-live-player-stats.md | 202 ++++++++++++++++ backend/app/rcon_admin_log_storage.py | 215 ++++++++++++++++-- backend/app/rcon_historical_storage.py | 19 +- backend/tests/test_current_match_payload.py | 164 +++++++++++++ frontend/assets/js/partida-actual.js | 202 +++++++++++++++- frontend/partida-actual.html | 2 +- 6 files changed, 783 insertions(+), 21 deletions(-) create mode 100644 ai/tasks/done/TASK-262-dedupe-current-match-live-player-stats.md diff --git a/ai/tasks/done/TASK-262-dedupe-current-match-live-player-stats.md b/ai/tasks/done/TASK-262-dedupe-current-match-live-player-stats.md new file mode 100644 index 0000000..e21222b --- /dev/null +++ b/ai/tasks/done/TASK-262-dedupe-current-match-live-player-stats.md @@ -0,0 +1,202 @@ +--- +id: TASK-262 +title: Dedupe current match live player stats +status: done +type: backend +team: Backend Senior +supporting_teams: + - Frontend Senior +roadmap_item: foundation +priority: high +--- + +# TASK-262 - Dedupe current match live player stats + +## Goal + +Ensure `/partida-actual.html`, section "Estadisticas en vivo", displays at most one row per player while preserving AdminLog-derived live stats. + +## Context + +Real observations showed duplicated player rows in the live stats table, for example: + +- `chino kudeiro | ALIADOS` +- `chino kudeiro | ALIADOS` +- `[LCM] acuario7 | NO DISPONIBLE` +- `[LCM] acuario7 | ALIADOS` +- `D-ibiz | NO DISPONIBLE` +- `D-ibiz | EJE` + +This section must not be confused with the combat feed. The combat feed uses `/api/current-match/kills`; live player stats use `/api/current-match/players`. + +The server population, for example `17/100`, is not the same as this table. The table is derived from recent AdminLog events and may contain fewer rows than the live online population. It still must not contain duplicates. + +## Mandatory Analysis + +### Frontend + +Files reviewed: + +- `frontend/assets/js/partida-actual.js` +- `frontend/partida-actual.html` + +Confirmed behavior: + +- Current match scoreboard refreshes every `30000` ms. +- Combat feed refreshes every `1500` ms. +- Live player stats refresh every `3000` ms. +- Current match scoreboard uses `/api/current-match?server={server}`. +- Combat feed uses `/api/current-match/kills?server={server}&limit=18`. +- Live player stats uses `/api/current-match/players?server={server}`. +- `renderPlayerStats(...)` currently sorts and renders `data.items` without a player-level dedupe guard. +- The visible counter currently says `Jugadores detectados: N`, which can be confused with online server population. + +### Backend + +Files reviewed: + +- `backend/app/payloads.py` +- `backend/app/rcon_admin_log_storage.py` +- `backend/app/rcon_admin_log_parser.py` +- `backend/app/routes.py` +- `backend/tests/test_current_match_payload.py` + +Confirmed behavior: + +- `/api/current-match/players` is routed to `build_current_match_player_stats_payload(server_slug=...)`. +- The payload delegates to `list_current_match_player_stats(server_key=..., ensure_storage=False)`. +- The endpoint represents players detected from safe AdminLog evidence in the current or recent match window, not a complete online roster. +- AdminLog event types used for player stats are `kill`, `team_switch`, `connected`, `disconnected`, `chat` and `message`. +- `connected` and `disconnected` events can create a player row with unknown team. +- `kill`, `team_switch` and `chat` can later provide `Allies` or `Axis`. +- Current aggregation keys by `player_id` when present, otherwise by `player_name.casefold()`. +- The endpoint currently exposes `player_id` and `player_name`; it does not expose separate `steam_id_64` or `epic_id` fields in this payload. +- `last_seen_at` exists in serialized items and is derived from event timestamps. + +## Root Cause + +The duplicate can happen when an early AdminLog event creates an id-less player bucket keyed by normalized name, and a later event for the same visible player includes a `player_id` and known team. The current aggregation treats those as separate keys (`name:{name}` and `id:{player_id}`), so the frontend receives two rows and renders them directly. + +## Functional Rules + +1. Live player stats must display at most one row per player. +2. Dedupe identity preference: + - `player_id` when available. + - `steam_id_64` when available. + - `epic_id` when available. + - normalized `player_name` fallback. +3. Unknown/no disponible plus allies or axis must collapse to the known team. +4. Stats split across duplicate buckets must be preserved. +5. Same display name with different `player_id` values must remain distinct. +6. If no reliable temporal team choice exists, known team wins over unknown. +7. `favorite_weapon` should prefer available weapon evidence over no data. +8. Counter copy must make clear that this is recent-event coverage, not online population. +9. Do not touch TeamKills except as needed to preserve existing aggregation. + +## Expected Files to Modify + +- `ai/tasks/in-progress/TASK-262-dedupe-current-match-live-player-stats.md` +- `backend/app/rcon_admin_log_storage.py` +- `backend/app/rcon_historical_storage.py` +- `backend/tests/test_current_match_payload.py` +- `frontend/assets/js/partida-actual.js` +- `frontend/partida-actual.html` + +## Constraints + +- Do not run `ai-platform run`. +- Do not commit. +- Do not push. +- Do not touch physical assets, `frontend/assets/img/`, maps, weapons, clans or brands. +- Do not touch scheduler or RCON server configuration. +- Do not change `27001`. +- Do not reactivate Elo/MMR. +- Do not reintroduce Comunidad Hispana #03. +- Do not touch `ai/system-metrics.md`. +- Do not include `tmp/`. +- Do not include `TASK-204`. +- Do not use `git add .`. +- Do not include unrelated previous changes. + +## Implementation Plan + +1. Fix backend aggregation so name-key buckets merge into the player-id bucket when later identity evidence arrives. +2. Keep known team when merging unknown and known evidence. +3. Preserve counters, weapon counts, connection state, sources and latest timestamp when merging. +4. Add frontend dedupe as a defensive guard before sorting/rendering. +5. Change player counter copy to "Jugadores con estadisticas recientes: N". +6. Add regression tests for unknown plus known team, same-team duplicates, split stats, distinct players, same name with different IDs and favorite weapon preservation. + +## Validation + +Required checks: + +- `python -m compileall backend/app` +- `cd backend; python -m unittest tests.test_current_match_payload` +- `node --check frontend/assets/js/partida-actual.js` + +Endpoint checks: + +- `GET /api/current-match/players?server=comunidad-hispana-01` +- `GET /api/current-match/players?server=comunidad-hispana-02` + +Visual checks: + +- `/partida-actual.html?server=comunidad-hispana-01` +- `/partida-actual.html?server=comunidad-hispana-02` + +## Outcome + +Implemented. + +Backend changes: + +- `/api/current-match/players` now merges an id-less name bucket into the stronger `player_id` bucket when later AdminLog evidence identifies the same player. +- Name-only events after one known identity now attach to that known identity when the name is unambiguous. +- Same visible name with different `player_id` values remains distinct. +- Known teams (`Allies`, `Axis`) are kept over unknown/`None`; unknown events no longer overwrite a known team. +- Stats are accumulated with per-event keys for kills, deaths, teamkills, deaths by teamkill and weapon counts, so merging buckets does not double count the same event. +- `favorite_weapon` continues to come from weapon frequency and is preserved when a real weapon exists. +- `rcon_historical_storage.py` was additionally touched because its SQLite connection helper left files locked on Windows during the required tests. The helper now commits/rolls back and closes writer connections explicitly; readonly helpers close as well. + +Frontend changes: + +- `renderPlayerStats(...)` now applies `dedupeCurrentMatchPlayerStats(items)` before sorting and rendering as a defensive guard. +- Counter copy changed from `Jugadores detectados: N` to `Jugadores con estadisticas recientes: N`. +- The static HTML default copy was updated to the same text. + +Endpoint validation: + +- `GET /api/current-match/players?server=comunidad-hispana-01` + - status: `ok` + - scope: `no-current-match-events` + - confidence: `stale-filtered` + - rows: `0` + - duplicate player names: none + - unknown plus known duplicates: none +- `GET /api/current-match/players?server=comunidad-hispana-02` + - status: `ok` + - scope: `open-admin-log-match-window` + - confidence: `admin-log-boundary` + - rows: `1` + - duplicate player names: none + - unknown plus known duplicates: none + +Validation run: + +- Passed: `python -m compileall backend/app` +- Passed: `cd backend; python -m unittest tests.test_current_match_payload` +- Passed: `node --check frontend/assets/js/partida-actual.js` +- Passed fallback HTML checks for: + - `http://127.0.0.1:8080/partida-actual.html?server=comunidad-hispana-01` + - `http://127.0.0.1:8080/partida-actual.html?server=comunidad-hispana-02` + +Visual validation note: + +- The Browser plugin skill was read, but the required `node_repl js` browser control tool was not exposed in this session. +- Local `playwright` was also not installed, so automated screenshot validation could not be completed. +- HTML-served copy was validated with PowerShell as a fallback. + +Working tree note: + +- Pre-existing unrelated changes remain outside this task, including `ai/system-metrics.md`, clan/map/weapon assets, `tmp/`, `TASK-204` and `TASK-242`. diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py index 2f2a2c6..bd4193e 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -38,7 +38,7 @@ def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path: resolved_path = initialize_rcon_historical_storage(db_path=db_path) - with connect_sqlite_writer(resolved_path) as connection: + with closing(connect_sqlite_writer(resolved_path)) as connection: connection.executescript( """ CREATE TABLE IF NOT EXISTS rcon_admin_log_events ( @@ -98,6 +98,7 @@ def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path: """ ) _ensure_canonical_message_column(connection) + connection.commit() return resolved_path @@ -121,7 +122,7 @@ def persist_rcon_admin_log_entries( inserted = 0 duplicates = 0 - with connect_sqlite_writer(resolved_path) as connection: + with closing(connect_sqlite_writer(resolved_path)) as connection: for entry in entries: parsed = parse_rcon_admin_log_entry(entry) raw_message = str(parsed.get("raw_message") or "") @@ -175,6 +176,7 @@ def persist_rcon_admin_log_entries( external_server_id=external_server_id, parsed=parsed, ) + connection.commit() return { "events_seen": len(entries), @@ -322,7 +324,7 @@ def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dic resolved_path = db_path or get_storage_path() initialize_rcon_admin_log_storage(db_path=resolved_path) - with sqlite3.connect(resolved_path) as connection: + with closing(sqlite3.connect(resolved_path)) as connection: connection.row_factory = sqlite3.Row rows = connection.execute( """ @@ -509,6 +511,7 @@ def list_current_match_player_stats( event_timestamp = row["event_timestamp"] event_type = str(row["event_type"] or "") if event_type == "kill": + event_key = _current_match_stat_event_key(row, payload) killer = _ensure_current_match_player( players, player_name=payload.get("killer_name"), @@ -529,15 +532,15 @@ def list_current_match_player_stats( ) if killer is not None: weapon = _safe_event_field(payload.get("weapon")) or "UNKNOWN" - _player_weapon_counts(killer)[weapon] += 1 + _add_current_match_player_weapon(killer, weapon, event_key) if payload.get("killer_team") and payload.get("killer_team") == payload.get("victim_team"): - killer["teamkills"] = int(killer["teamkills"]) + 1 + _add_current_match_player_stat(killer, "teamkills", event_key) else: - killer["kills"] = int(killer["kills"]) + 1 + _add_current_match_player_stat(killer, "kills", event_key) if victim is not None: - victim["deaths"] = int(victim["deaths"]) + 1 + _add_current_match_player_stat(victim, "deaths", event_key) if payload.get("killer_team") and payload.get("killer_team") == payload.get("victim_team"): - victim["deaths_by_teamkill"] = int(victim["deaths_by_teamkill"]) + 1 + _add_current_match_player_stat(victim, "deaths_by_teamkill", event_key) continue if event_type == "team_switch": @@ -739,7 +742,7 @@ def get_latest_rcon_player_profile_summaries( resolved_path = db_path or get_storage_path() initialize_rcon_admin_log_storage(db_path=resolved_path) placeholders = ",".join("?" for _ in requested_ids) - with sqlite3.connect(resolved_path) as connection: + with closing(sqlite3.connect(resolved_path)) as connection: connection.row_factory = sqlite3.Row rows = connection.execute( f""" @@ -846,7 +849,7 @@ def _ensure_current_match_player( ) -> dict[str, object] | None: safe_name = _safe_event_field(player_name) safe_id = _safe_event_field(player_id) - key = _current_match_player_key(safe_id, safe_name) + key = _resolve_current_match_player_key(players, player_id=safe_id, player_name=safe_name) if key is None: return None player = players.setdefault( @@ -862,6 +865,8 @@ def _ensure_current_match_player( "is_connected": None, "last_seen_at": None, "_weapon_counts": Counter(), + "_weapon_event_keys": {}, + "_stat_event_keys": {}, "_sources": set(), }, ) @@ -872,7 +877,10 @@ def _ensure_current_match_player( 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: + if safe_team and ( + _is_known_current_match_team(safe_team) + or not _is_known_current_match_team(player.get("team")) + ): player["team"] = safe_team if is_connected is not None: player["is_connected"] = is_connected @@ -885,15 +893,194 @@ def _ensure_current_match_player( return player +def _resolve_current_match_player_key( + players: dict[str, dict[str, object]], + *, + player_id: str | None, + player_name: str | None, +) -> str | None: + id_key = f"id:{player_id}" if player_id else None + name_key = _current_match_player_name_key(player_name) + if id_key is not None: + if name_key is not None and name_key in players: + named_player = players.pop(name_key) + if id_key in players: + _merge_current_match_player(players[id_key], named_player) + else: + players[id_key] = named_player + return id_key + if name_key is None: + return None + + normalized_name = _normalize_current_match_player_name(player_name) + matching_id_keys = [ + key + for key, player in players.items() + if key.startswith("id:") + and _normalize_current_match_player_name(player.get("player_name")) == normalized_name + ] + if len(matching_id_keys) == 1: + return matching_id_keys[0] + return name_key + + 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 + return _current_match_player_name_key(player_name) + + +def _current_match_player_name_key(player_name: object) -> str | None: + normalized = _normalize_current_match_player_name(player_name) + return f"name:{normalized}" if normalized else None + + +def _normalize_current_match_player_name(player_name: object) -> str: + return " ".join(str(player_name or "").strip().casefold().split()) + + +def _merge_current_match_player( + destination: dict[str, object], + source: Mapping[str, object], +) -> None: + if not destination.get("player_id") and source.get("player_id"): + destination["player_id"] = source.get("player_id") + + source_name = _safe_event_field(source.get("player_name")) + if source_name: + destination_name = _safe_event_field(destination.get("player_name")) + if destination_name is None or len(source_name) >= len(destination_name): + destination["player_name"] = source_name + + source_team = source.get("team") + if _is_known_current_match_team(source_team) or not _is_known_current_match_team(destination.get("team")): + if _safe_event_field(source_team): + destination["team"] = source_team + + _merge_current_match_stat_events(destination, source) + _merge_current_match_weapon_events(destination, source) + + source_last_seen = _safe_event_field(source.get("last_seen_at")) + destination_last_seen = _safe_event_field(destination.get("last_seen_at")) + if source_last_seen and (destination_last_seen is None or source_last_seen > destination_last_seen): + destination["last_seen_at"] = source_last_seen + if source.get("is_connected") is not None: + destination["is_connected"] = source.get("is_connected") + elif destination.get("is_connected") is None and source.get("is_connected") is not None: + destination["is_connected"] = source.get("is_connected") + + destination_sources = destination.setdefault("_sources", set()) + source_sources = source.get("_sources", set()) + if isinstance(destination_sources, set) and isinstance(source_sources, set): + destination_sources.update(source_sources) + + +def _merge_current_match_stat_events( + destination: dict[str, object], + source: Mapping[str, object], +) -> None: + destination_keys = _stat_event_keys(destination) + source_keys = _stat_event_keys(source) + for stat_name in ("kills", "deaths", "teamkills", "deaths_by_teamkill"): + source_count = int(source.get(stat_name) or 0) + if source_count == 0: + continue + source_stat_keys = source_keys.get(stat_name, set()) + destination_stat_keys = destination_keys.setdefault(stat_name, set()) + overlap = len(destination_stat_keys & source_stat_keys) + destination[stat_name] = int(destination.get(stat_name) or 0) + max(0, source_count - overlap) + destination_stat_keys.update(source_stat_keys) + + +def _merge_current_match_weapon_events( + destination: dict[str, object], + source: Mapping[str, object], +) -> None: + destination_counts = _player_weapon_counts(destination) + source_counts = _player_weapon_counts(source) + destination_keys = _weapon_event_keys(destination) + source_keys = _weapon_event_keys(source) + for weapon, count in source_counts.items(): + source_weapon_keys = source_keys.get(weapon, set()) + destination_weapon_keys = destination_keys.setdefault(weapon, set()) + overlap = len(destination_weapon_keys & source_weapon_keys) + destination_counts[weapon] += max(0, int(count) - overlap) + destination_weapon_keys.update(source_weapon_keys) + + +def _is_known_current_match_team(value: object) -> bool: + normalized = str(value or "").strip().casefold() + return normalized in {"allies", "allied", "axis"} + + +def _current_match_stat_event_key(row: Mapping[str, object], payload: Mapping[str, object]) -> str: + parts = [ + "kill", + _row_value(row, "server_time"), + _row_value(row, "event_timestamp"), + payload.get("killer_name"), + payload.get("killer_team"), + payload.get("victim_name"), + payload.get("victim_team"), + payload.get("weapon"), + ] + normalized_parts = [_normalize_current_match_event_value(part) for part in parts] + semantic_key = "|".join(normalized_parts) + return semantic_key if any(normalized_parts[1:]) else f"row:{_row_value(row, 'id')}" + + +def _row_value(row: Mapping[str, object], key: str) -> object: + if isinstance(row, Mapping): + return row.get(key) + try: + return row[key] # type: ignore[index] + except (IndexError, KeyError, TypeError): + return None + + +def _normalize_current_match_event_value(value: object) -> str: + return " ".join(str(value or "").strip().casefold().split()) + + +def _add_current_match_player_stat( + player: dict[str, object], + stat_name: str, + event_key: str, +) -> None: + event_keys = _stat_event_keys(player).setdefault(stat_name, set()) + if event_key in event_keys: + return + player[stat_name] = int(player.get(stat_name) or 0) + 1 + event_keys.add(event_key) + + +def _add_current_match_player_weapon( + player: dict[str, object], + weapon: str, + event_key: str, +) -> None: + weapon_keys = _weapon_event_keys(player).setdefault(weapon, set()) + if event_key in weapon_keys: + return + _player_weapon_counts(player)[weapon] += 1 + weapon_keys.add(event_key) + + +def _stat_event_keys(player: Mapping[str, object]) -> dict[str, set[str]]: + event_keys = player.get("_stat_event_keys") + if isinstance(event_keys, dict): + return event_keys + return {} + + +def _weapon_event_keys(player: Mapping[str, object]) -> dict[str, set[str]]: + event_keys = player.get("_weapon_event_keys") + if isinstance(event_keys, dict): + return event_keys + return {} def _player_weapon_counts(player: Mapping[str, object]) -> Counter[str]: diff --git a/backend/app/rcon_historical_storage.py b/backend/app/rcon_historical_storage.py index bf97289..c0d8179 100644 --- a/backend/app/rcon_historical_storage.py +++ b/backend/app/rcon_historical_storage.py @@ -5,6 +5,8 @@ from __future__ import annotations import json import sqlite3 from collections.abc import Mapping +from collections.abc import Iterator +from contextlib import closing, contextmanager from datetime import datetime, timezone from pathlib import Path @@ -829,12 +831,21 @@ def get_rcon_historical_competitive_window_by_session( } -def _connect(db_path: Path) -> sqlite3.Connection: - return connect_sqlite_writer(db_path) +@contextmanager +def _connect(db_path: Path) -> Iterator[sqlite3.Connection]: + connection = connect_sqlite_writer(db_path) + try: + yield connection + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() -def _connect_readonly(db_path: Path) -> sqlite3.Connection: - return connect_sqlite_readonly(db_path) +def _connect_readonly(db_path: Path): + return closing(connect_sqlite_readonly(db_path)) def _resolve_db_path(db_path: Path | None) -> Path: diff --git a/backend/tests/test_current_match_payload.py b/backend/tests/test_current_match_payload.py index a0ac1b0..16afdab 100644 --- a/backend/tests/test_current_match_payload.py +++ b/backend/tests/test_current_match_payload.py @@ -1,5 +1,7 @@ from http import HTTPStatus from datetime import datetime, timezone +from pathlib import Path +import tempfile import unittest from unittest.mock import MagicMock, patch @@ -498,6 +500,129 @@ def test_current_match_player_stats_filter_stale_recent_events(tmp_path): class CurrentMatchPublicEndpointHardeningTests(unittest.TestCase): + def test_current_match_player_stats_deduplicates_unknown_and_known_team(self) -> None: + stats = _build_admin_log_player_stats( + [ + "[1:00 min (100)] MATCH START Mortain Warfare", + "[2:00 min (120)] CONNECTED Merge Allies ()", + ( + "[3:00 min (140)] KILL: Merge Allies(Allies/steam-merge-allies) -> " + "Axis Target(Axis/steam-axis-target) with M1 GARAND" + ), + "[4:00 min (160)] CONNECTED Merge Axis ()", + ( + "[5:00 min (180)] KILL: Merge Axis(Axis/steam-merge-axis) -> " + "Allies Target(Allies/steam-allies-target) with MP40" + ), + ] + ) + + by_name = _player_stats_by_name(stats) + + self.assertEqual(_count_player_stats_by_name(stats, "Merge Allies"), 1) + self.assertEqual(by_name["Merge Allies"]["team"], "Allies") + self.assertEqual(by_name["Merge Allies"]["kills"], 1) + self.assertEqual(_count_player_stats_by_name(stats, "Merge Axis"), 1) + self.assertEqual(by_name["Merge Axis"]["team"], "Axis") + self.assertEqual(by_name["Merge Axis"]["kills"], 1) + + def test_current_match_player_stats_deduplicates_same_known_team_rows(self) -> None: + stats = _build_admin_log_player_stats( + [ + "[1:00 min (100)] MATCH START Mortain Warfare", + "[2:00 min (120)] CHAT[Team][Known Duplicate(Allies/)]: listo", + ( + "[3:00 min (140)] KILL: Known Duplicate(Allies/steam-known-duplicate) -> " + "Axis Target(Axis/steam-axis-target) with M1 GARAND" + ), + ] + ) + + by_name = _player_stats_by_name(stats) + + self.assertEqual(_count_player_stats_by_name(stats, "Known Duplicate"), 1) + self.assertEqual(by_name["Known Duplicate"]["team"], "Allies") + self.assertEqual(by_name["Known Duplicate"]["kills"], 1) + + def test_current_match_player_stats_preserves_split_stats_when_deduplicating(self) -> None: + stats = _build_admin_log_player_stats( + [ + "[1:00 min (100)] MATCH START Mortain Warfare", + ( + "[2:00 min (120)] KILL: Split Stats(Allies/) -> " + "First Victim(Axis/steam-first-victim) with MP40" + ), + ( + "[3:00 min (140)] KILL: Axis Killer(Axis/steam-axis-killer) -> " + "Split Stats(Allies/steam-split-stats) with M1 GARAND" + ), + ] + ) + + player = _player_stats_by_name(stats)["Split Stats"] + + self.assertEqual(_count_player_stats_by_name(stats, "Split Stats"), 1) + self.assertEqual(player["team"], "Allies") + self.assertEqual(player["kills"], 1) + self.assertEqual(player["deaths"], 1) + self.assertEqual(player["teamkills"], 0) + self.assertEqual(player["deaths_by_teamkill"], 0) + self.assertEqual(player["favorite_weapon"], "MP40") + + def test_current_match_player_stats_keeps_distinct_players(self) -> None: + stats = _build_admin_log_player_stats( + [ + "[1:00 min (100)] MATCH START Mortain Warfare", + "[2:00 min (120)] CONNECTED First Player (steam-first)", + "[3:00 min (140)] CONNECTED Second Player (steam-second)", + ] + ) + + names = {item["player_name"] for item in stats["items"]} + + self.assertEqual(names, {"First Player", "Second Player"}) + + def test_current_match_player_stats_keeps_same_name_with_different_player_ids(self) -> None: + stats = _build_admin_log_player_stats( + [ + "[1:00 min (100)] MATCH START Mortain Warfare", + ( + "[2:00 min (120)] KILL: Shared Name(Allies/steam-shared-one) -> " + "First Victim(Axis/steam-first-victim) with M1 GARAND" + ), + ( + "[3:00 min (140)] KILL: Shared Name(Allies/steam-shared-two) -> " + "Second Victim(Axis/steam-second-victim) with BAR" + ), + ] + ) + + shared_rows = [ + item for item in stats["items"] if item["player_name"] == "Shared Name" + ] + + self.assertEqual(len(shared_rows), 2) + self.assertEqual( + {item["player_id"] for item in shared_rows}, + {"steam-shared-one", "steam-shared-two"}, + ) + + def test_current_match_player_stats_preserves_real_favorite_weapon(self) -> None: + stats = _build_admin_log_player_stats( + [ + "[1:00 min (100)] MATCH START Mortain Warfare", + "[2:00 min (120)] CONNECTED Weapon Player ()", + ( + "[3:00 min (140)] KILL: Weapon Player(Allies/steam-weapon-player) -> " + "Axis Target(Axis/steam-axis-target) with M1 GARAND" + ), + ] + ) + + player = _player_stats_by_name(stats)["Weapon Player"] + + self.assertEqual(player["favorite_weapon"], "M1 GARAND") + def test_kill_feed_degrades_when_admin_log_read_fails(self) -> None: with patch.object( payloads, @@ -754,3 +879,42 @@ class _FakeLiveSource: if self.collect_error is not None: raise self.collect_error return self.collect_payload + + +def _build_admin_log_player_stats(messages: list[str]) -> dict[str, object]: + with tempfile.TemporaryDirectory() as temp_dir: + db_path = Path(temp_dir) / "admin-log.sqlite3" + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + }, + entries=[ + { + "timestamp": f"2026-05-21T10:{index:02d}:00Z", + "message": message, + } + for index, message in enumerate(messages) + ], + db_path=db_path, + ) + return list_current_match_player_stats( + server_key="comunidad-hispana-01", + db_path=db_path, + ) + + +def _player_stats_by_name(stats: dict[str, object]) -> dict[str, dict[str, object]]: + return { + str(item["player_name"]): item + for item in stats["items"] + if isinstance(item, dict) + } + + +def _count_player_stats_by_name(stats: dict[str, object], player_name: str) -> int: + return sum( + 1 + for item in stats["items"] + if isinstance(item, dict) and item.get("player_name") == player_name + ) diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js index 6817632..99cd4c1 100644 --- a/frontend/assets/js/partida-actual.js +++ b/frontend/assets/js/partida-actual.js @@ -565,7 +565,9 @@ function renderKillFeedWeaponIcon(weapon) { } function renderPlayerStats(data, nodes, state) { - const items = Array.isArray(data.items) ? sortPlayerStats(data.items) : []; + const items = Array.isArray(data.items) + ? sortPlayerStats(dedupeCurrentMatchPlayerStats(data.items)) + : []; renderDetectedPlayerCount(items.length, nodes); if (!nodes.playerStatsShell || !nodes.playerStatsState) { return; @@ -604,10 +606,206 @@ function renderPlayerStats(data, nodes, state) { function renderDetectedPlayerCount(count, nodes) { if (nodes.playerCount) { - nodes.playerCount.textContent = `Jugadores detectados: ${count}`; + nodes.playerCount.textContent = `Jugadores con estadisticas recientes: ${count}`; } } +function dedupeCurrentMatchPlayerStats(items) { + const byKey = new Map(); + items.forEach((item) => { + const key = resolvePlayerStatsDedupeKey(byKey, item); + if (!key) { + return; + } + const current = byKey.get(key); + byKey.set(key, current ? mergePlayerStatsRows(current, item) : { ...item }); + }); + return [...byKey.values()]; +} + +function resolvePlayerStatsDedupeKey(byKey, item) { + const identityKey = getPlayerStatsIdentityKey(item); + const nameKey = getPlayerStatsNameKey(item); + if (identityKey) { + if (nameKey && byKey.has(nameKey)) { + const namedRow = byKey.get(nameKey); + byKey.delete(nameKey); + byKey.set(identityKey, byKey.has(identityKey) + ? mergePlayerStatsRows(byKey.get(identityKey), namedRow) + : namedRow); + } + return identityKey; + } + if (!nameKey) { + return ""; + } + const matchingIdentityKeys = [...byKey.entries()] + .filter(([key, row]) => key.startsWith("id:") && getPlayerStatsNameKey(row) === nameKey) + .map(([key]) => key); + return matchingIdentityKeys.length === 1 ? matchingIdentityKeys[0] : nameKey; +} + +function getPlayerStatsIdentityKey(item) { + const playerId = normalizePlayerStatsDedupeValue(item?.player_id); + if (playerId) { + return `id:player:${playerId}`; + } + const steamId = normalizePlayerStatsDedupeValue(item?.steam_id_64); + if (steamId) { + return `id:steam:${steamId}`; + } + const epicId = normalizePlayerStatsDedupeValue(item?.epic_id); + if (epicId) { + return `id:epic:${epicId}`; + } + return ""; +} + +function getPlayerStatsNameKey(item) { + const name = normalizePlayerStatsDedupeValue(item?.player_name); + return name ? `name:${name}` : ""; +} + +function normalizePlayerStatsDedupeValue(value) { + return String(value ?? "").trim().toLowerCase().replace(/\s+/g, " "); +} + +function mergePlayerStatsRows(current, candidate) { + if (areEquivalentPlayerStatsRows(current, candidate)) { + return pickRicherPlayerStatsRow(current, candidate); + } + const merged = { + ...pickRicherPlayerStatsRow(current, candidate), + player_id: current.player_id || candidate.player_id, + steam_id_64: current.steam_id_64 || candidate.steam_id_64, + epic_id: current.epic_id || candidate.epic_id, + player_name: pickPlayerStatsName(current.player_name, candidate.player_name), + team: pickPlayerStatsTeam(current, candidate), + kills: toStatNumber(current.kills) + toStatNumber(candidate.kills), + deaths: toStatNumber(current.deaths) + toStatNumber(candidate.deaths), + teamkills: toStatNumber(current.teamkills) + toStatNumber(candidate.teamkills), + deaths_by_teamkill: + toStatNumber(current.deaths_by_teamkill) + toStatNumber(candidate.deaths_by_teamkill), + favorite_weapon: pickPlayerStatsFavoriteWeapon(current, candidate), + last_seen_at: pickLatestPlayerStatsValue(current.last_seen_at, candidate.last_seen_at), + source: mergePlayerStatsSources(current.source, candidate.source), + }; + if (candidate.connected !== undefined || current.connected !== undefined) { + merged.connected = pickLatestPlayerStatsConnected(current, candidate); + merged.is_connected = merged.connected; + } + return merged; +} + +function areEquivalentPlayerStatsRows(current, candidate) { + return [ + "team", + "kills", + "deaths", + "teamkills", + "deaths_by_teamkill", + "favorite_weapon", + "last_seen_at", + ].every((field) => + normalizePlayerStatsDedupeValue(current?.[field]) === + normalizePlayerStatsDedupeValue(candidate?.[field]), + ); +} + +function pickRicherPlayerStatsRow(current, candidate) { + return getPlayerStatsMetadataScore(candidate) > getPlayerStatsMetadataScore(current) + ? { ...candidate } + : { ...current }; +} + +function getPlayerStatsMetadataScore(item) { + return [ + Boolean(normalizePlayerStatsDedupeValue(item?.player_id)), + Boolean(normalizePlayerStatsDedupeValue(item?.steam_id_64)), + Boolean(normalizePlayerStatsDedupeValue(item?.epic_id)), + getPlayerTeamDisplay(item?.team).key !== "unknown", + Boolean(getAvailableFavoriteWeapon(item?.favorite_weapon)), + Boolean(normalizePlayerStatsDedupeValue(item?.last_seen_at)), + ].filter(Boolean).length; +} + +function pickPlayerStatsName(currentName, candidateName) { + const current = String(currentName || "").trim(); + const candidate = String(candidateName || "").trim(); + if (!current) { + return candidate; + } + if (!candidate) { + return current; + } + return candidate.length > current.length ? candidate : current; +} + +function pickPlayerStatsTeam(current, candidate) { + const currentTeam = getPlayerTeamDisplay(current?.team); + const candidateTeam = getPlayerTeamDisplay(candidate?.team); + if (candidateTeam.key !== "unknown" && currentTeam.key === "unknown") { + return candidate.team; + } + if (candidateTeam.key !== "unknown" && isPlayerStatsCandidateNewer(current, candidate)) { + return candidate.team; + } + return current.team || candidate.team; +} + +function pickPlayerStatsFavoriteWeapon(current, candidate) { + const currentWeapon = getAvailableFavoriteWeapon(current?.favorite_weapon); + const candidateWeapon = getAvailableFavoriteWeapon(candidate?.favorite_weapon); + if (!currentWeapon) { + return candidateWeapon || current.favorite_weapon || candidate.favorite_weapon; + } + if (!candidateWeapon) { + return currentWeapon; + } + return getPlayerStatsTotal(candidate) > getPlayerStatsTotal(current) + ? candidateWeapon + : currentWeapon; +} + +function getAvailableFavoriteWeapon(value) { + const normalized = normalizePlayerStatsDedupeValue(value); + return normalized && normalized !== "no disponible" ? String(value || "").trim() : ""; +} + +function pickLatestPlayerStatsValue(current, candidate) { + return String(candidate || "") > String(current || "") ? candidate : current; +} + +function pickLatestPlayerStatsConnected(current, candidate) { + if (isPlayerStatsCandidateNewer(current, candidate)) { + return candidate.connected ?? candidate.is_connected ?? current.connected ?? current.is_connected; + } + return current.connected ?? current.is_connected ?? candidate.connected ?? candidate.is_connected; +} + +function isPlayerStatsCandidateNewer(current, candidate) { + return String(candidate?.last_seen_at || "") > String(current?.last_seen_at || ""); +} + +function getPlayerStatsTotal(item) { + return ( + toStatNumber(item?.kills) + + toStatNumber(item?.deaths) + + toStatNumber(item?.teamkills) + + toStatNumber(item?.deaths_by_teamkill) + ); +} + +function mergePlayerStatsSources(current, candidate) { + const sources = new Set( + [current, candidate] + .flatMap((value) => String(value || "").split(",")) + .map((value) => value.trim()) + .filter(Boolean), + ); + return [...sources].sort().join(","); +} + function sortPlayerStats(items) { return [...items].sort( (left, right) => diff --git a/frontend/partida-actual.html b/frontend/partida-actual.html index d6241cf..0652bfb 100644 --- a/frontend/partida-actual.html +++ b/frontend/partida-actual.html @@ -132,7 +132,7 @@ Las estadisticas en vivo apareceran cuando haya datos suficientes.

- Jugadores detectados: 0 + Jugadores con estadisticas recientes: 0