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