Dedupe current match live player stats

This commit is contained in:
devRaGonSa
2026-06-17 15:27:15 +02:00
parent 335fb80d65
commit e389abfafe
6 changed files with 783 additions and 21 deletions

View File

@@ -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`.

View File

@@ -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]:

View File

@@ -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:

View File

@@ -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
)

View File

@@ -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) =>

View File

@@ -132,7 +132,7 @@
Las estadisticas en vivo apareceran cuando haya datos suficientes.
</p>
<p class="current-match-player-count" id="current-match-player-count">
Jugadores detectados: 0
Jugadores con estadisticas recientes: 0
</p>
</div>
</div>