diff --git a/ai/system-metrics.md b/ai/system-metrics.md
index 3bf6f3f..3456941 100644
--- a/ai/system-metrics.md
+++ b/ai/system-metrics.md
@@ -19,3 +19,4 @@ Date | Task | Duration | Result | Notes
2026-05-19T17:20:44 | worker-cycle | 2240.22 sec | success | codex-runner
2026-05-21T14:43:44 | worker-cycle | 5580.9 sec | success | codex-runner
2026-05-21T15:17:18 | worker-cycle | 688.86 sec | success | codex-runner
+2026-05-21T18:59:27 | worker-cycle | 527.54 sec | success | codex-runner
diff --git a/ai/tasks/done/TASK-154-current-match-killfeed-overlay-layout.md b/ai/tasks/done/TASK-154-current-match-killfeed-overlay-layout.md
new file mode 100644
index 0000000..cf50026
--- /dev/null
+++ b/ai/tasks/done/TASK-154-current-match-killfeed-overlay-layout.md
@@ -0,0 +1,198 @@
+# TASK-154 - Current match killfeed overlay layout
+
+Status: in-progress
+
+## Goal
+
+Redesign the current-match kill feed frontend so it behaves like a compact live killfeed overlay.
+
+## Background
+
+The current-match page already renders a kill feed using:
+
+- `GET /api/current-match/kills?server=...`
+
+The feed currently appears as a vertical list of large historical-style cards. This is not the desired UX.
+
+## Desired UX
+
+The kill feed should look like a compact live FPS-style overlay:
+
+- A rectangular live panel.
+- Events rendered as compact rows/chips.
+- Each event should show:
+ - killer name
+ - weapon icon or weapon label fallback
+ - victim name
+- Events should be arranged in three visual columns inside the panel.
+- New events should appear progressively.
+- Older events should move left and eventually disappear.
+- The feed should feel like a real-time combat screen, not a historical list.
+
+The user explicitly wants:
+
+- "una especie de pantalla en tiempo real"
+- "simplemente se muestre el texto, el que mata, el arma y alguien mata"
+- "iconos del arma que se utiliza"
+- "una pequeña pantalla rectangular"
+- "se irá poniendo de arriba abajo en tres columnas"
+- "se irá desplazando hacia la izquierda e irán desapareciendo las más antiguas"
+
+## Scope
+
+Replace the current large-card kill feed with a compact live overlay on the current-match page.
+
+Allowed changes:
+
+- `frontend/assets/js/partida-actual.js`
+- `frontend/partida-actual.html` if needed
+- `frontend/assets/css/historico.css` or the relevant CSS used by `partida-actual.html`
+- `frontend/assets/css/styles.css` only if the current-match page depends on it
+- `frontend/assets/img/weapons/*` if local weapon icons/placeholders are added
+- backend only if a small weapon normalization field is needed, but prefer frontend-side mapping first
+- focused tests or node validation
+
+## Constraints - DO NOT BREAK
+
+- Do not break `/api/current-match/kills`.
+- Do not expose raw AdminLog lines.
+- Do not fabricate kill events.
+- Do not show stale kills as live kills.
+- Do not break the current-match scoreboard/header.
+- Do not break historical match detail pages.
+- Do not query RCON directly from the frontend.
+- Do not depend on server #03.
+- Do not require external/CDN assets at runtime.
+- If weapon icons are added, they must be local/static assets or generated lightweight inline/SVG placeholders.
+- Keep the UI responsive.
+
+## Files to inspect first
+
+Read:
+
+- `frontend/partida-actual.html`
+- `frontend/assets/js/partida-actual.js`
+- the CSS currently used by `frontend/partida-actual.html`
+- focused current-match kill feed tests or validation scripts if present
+
+Inspect the current kill feed rendering, `event_id` handling, scope copy, and current polling behavior before changing code.
+
+## Implementation requirements
+
+### 1. Compact live panel
+
+Replace the current large-card feed rendering with a compact live killfeed panel.
+
+The panel must be visually rectangular and compact. It should look like a live combat overlay, not a list of historical cards.
+
+### 2. Layout
+
+- Use a three-column visual layout on desktop.
+- Events should fill vertically within a column, then continue through the next visual position.
+- Newer events should be visually prioritized.
+- Older events should shift left and disappear once the maximum number of visible events is exceeded.
+- On narrow/mobile widths, fall back to one or two columns without overflow.
+
+### 3. Event content
+
+Each kill event must show:
+
+- killer name
+- weapon icon or weapon label
+- victim name
+- optional timestamp only if it does not make the UI noisy
+- teamkill indicator if `is_teamkill` is true
+
+### 4. Weapon icons
+
+Add a safe mapping for common weapons currently seen in AdminLog examples:
+
+- `M1 GARAND`
+- `MP40`
+- `M1A1 THOMPSON`
+- `UNKNOWN`
+
+Prefer local SVG/icon placeholders if real weapon assets are not available.
+
+- Do not hotlink external images.
+- Unknown weapons must show a generic weapon icon/label fallback.
+- The icon mapping should be easy to extend later.
+
+### 5. Motion and transition
+
+- Use CSS transitions/animations only if they are subtle.
+- Avoid layout jumps.
+- Respect users with reduced motion if possible.
+- The feed should not flicker every poll.
+
+### 6. Deduplication
+
+- Preserve existing `event_id` deduplication.
+- Re-rendering must not duplicate rows.
+- Repeated polling must keep already visible events stable.
+
+### 7. Maximum visible events
+
+- Limit visible events to a reasonable number, for example 12 or 15.
+- Older events should be dropped from the visual panel.
+- Do not render an infinitely growing list.
+
+### 8. Copy
+
+Use these messages:
+
+- If there are no events: "Todavía no se han detectado bajas en esta partida."
+- If scope is open match window: "Bajas detectadas en la partida actual."
+- If scope is `recent-admin-log-window`: "Cobertura parcial desde AdminLog reciente."
+- If stale/no current events: "Sin bajas recientes asociadas a la partida actual."
+
+### 9. Accessibility
+
+- Keep `aria-live` polite or equivalent.
+- Event text must remain readable even if icons fail.
+
+## Validation
+
+Run:
+
+- `node --check frontend/assets/js/partida-actual.js`
+- any existing frontend validation if available
+
+## Manual verification checklist
+
+- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-01`.
+- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-02`.
+- Verify kill feed appears as a compact rectangular live overlay.
+- Verify events render as killer -> weapon/icon -> victim.
+- Verify it uses three columns on desktop.
+- Verify old events disappear instead of growing endlessly.
+- Verify repeated polling does not duplicate events.
+- Verify teamkills are visually distinguishable.
+- Verify unknown weapons use a clean fallback.
+- Verify no raw AdminLog line is shown.
+- Verify no external image URLs are required.
+
+## Expected outcome
+
+The current-match kill feed looks and behaves like a compact live FPS-style killfeed overlay with local/fallback weapon icon support.
+
+## AI Platform lifecycle
+
+After implementation and validation:
+
+- Move this task according to the lifecycle defined in `AGENTS.md`.
+- Do not mark unrelated tasks as done automatically.
+
+## Outcome
+
+- Replaced the historical-style kill cards with a capped 15-event rectangular overlay in `partida-actual`.
+- Kept `event_id` deduplication and avoided poll flicker by only re-rendering when the visible event-id set changes.
+- Rendered older visible events first so they occupy the left side of the three-column desktop panel while newer events remain visually prioritized on the right.
+- Added local text-glyph weapon placeholders for `M1 GARAND`, `MP40`, `M1A1 THOMPSON` and unknown/other weapons without external assets.
+- Validation run:
+ - `node --check frontend/assets/js/partida-actual.js`
+ - `scripts/run-historical-ui-regression-tests.ps1`
+ - `git diff --check`
+ - `git diff --name-only`
+- Scope review: changed product files are `frontend/assets/js/partida-actual.js` and `frontend/assets/css/historico.css`.
+- Manual/rendered Browser QA remains to be repeated when the Browser automation entry point is exposed; the local frontend and backend current-match endpoints were reachable during validation.
diff --git a/ai/tasks/done/TASK-155-current-match-live-player-stats.md b/ai/tasks/done/TASK-155-current-match-live-player-stats.md
new file mode 100644
index 0000000..f904903
--- /dev/null
+++ b/ai/tasks/done/TASK-155-current-match-live-player-stats.md
@@ -0,0 +1,245 @@
+# TASK-155 - Current match live player stats
+
+Status: in-progress
+
+## Goal
+
+Investigate and implement live player/statistics rendering for the current-match page using the safest available data source.
+
+## Background
+
+The current-match page has an "Estadísticas en vivo" section, but it currently remains a placeholder:
+
+> "Las estadísticas en vivo aparecerán cuando haya datos suficientes."
+
+The user reports that players are not being shown.
+
+Known context:
+
+- `/api/current-match` exposes players and team player counts from RCON `getSession`, but those counts may be marked as `rcon-session-unverified`.
+- Public scoreboard can show player population even when RCON `getSession` reports 0.
+- AdminLog kill events already expose player names through normalized kill feed rows.
+- Historical materialization already derives player stats from AdminLog data for completed matches.
+- The current-match page should eventually show useful live player rows, but only if data is reliable enough.
+
+## Scope
+
+Investigate the available live player data sources and wire the current-match page to a safe current player statistics payload.
+
+Allowed changes:
+
+- `backend/app/routes.py`
+- `backend/app/payloads.py`
+- `backend/app/rcon_admin_log_storage.py`
+- backend read-model/helper files if needed
+- `frontend/assets/js/partida-actual.js`
+- `frontend/partida-actual.html`
+- `frontend/assets/css/historico.css` or relevant CSS
+- focused backend tests
+
+## Constraints - DO NOT BREAK
+
+- Do not fabricate player stats.
+- Do not show an empty/misleading table as if it were real data.
+- Do not show RCON `getSession` `playerCount=0` as reliable if quality is `rcon-session-unverified`.
+- Do not expose raw AdminLog lines.
+- Do not expose admin-only/sensitive data.
+- Do not break historical materialization.
+- Do not break historical match detail stats.
+- Do not query RCON directly from the frontend.
+- Do not depend on server #03.
+- Do not scrape the public scoreboard unless explicitly chosen and documented as a safe server-side fallback.
+- Keep current public scoreboard URLs trusted and without `/games`.
+
+## Files to inspect first
+
+Read:
+
+- `frontend/partida-actual.html`
+- `frontend/assets/js/partida-actual.js`
+- `backend/app/routes.py`
+- `backend/app/payloads.py`
+- `backend/app/rcon_admin_log_storage.py`
+- focused tests and historical player stat materialization helpers related to current-match or AdminLog data
+
+Inspect the existing RCON session payload, current kill feed freshness logic, open AdminLog match window support, and historical player stat aggregation before choosing a source.
+
+## Required investigation before implementation
+
+Perform RCA first and determine what live player data sources currently exist:
+
+- RCON session payload
+- any RCON player-list command/wrapper already available in the codebase
+- AdminLog kill/death events
+- existing historical player stat materialization
+- current open AdminLog match window if available
+
+Document the chosen source in the done task notes:
+
+- what was available
+- what was not available
+- whether stats are complete, partial, or event-derived
+
+## Implementation requirements
+
+### 1. Backend endpoint
+
+Add or extend a backend endpoint for current-match player stats.
+
+Preferred route:
+
+- `GET /api/current-match/players?server=...`
+
+### 2. Supported servers
+
+Support:
+
+- `comunidad-hispana-01`
+- `comunidad-hispana-02`
+
+### 3. Unknown server values
+
+- Return a safe 400/404 style response.
+- Do not query arbitrary targets.
+
+### 4. Data model
+
+The endpoint should return:
+
+- `server_slug`
+- `server_name`
+- `scope`
+- `confidence`
+- `source`
+- `captured_at` or `updated_at`
+- `items`: array of player rows
+
+### 5. Player row fields
+
+Include only fields that can be supported reliably:
+
+- `player_name`
+- `team` if known
+- `kills` if known
+- `deaths` if known
+- `teamkills` if known
+- `deaths_by_teamkill` if known
+- `favorite_weapon` or `most_used_weapon` if known
+- `last_seen_at` if known
+- `confidence`/`source` if needed
+
+### 6. AdminLog-derived data
+
+If using AdminLog-derived data:
+
+- Clearly mark scope/confidence as partial/event-derived.
+- Only include players observed in recent/current match event windows.
+- Do not imply this is the full server roster.
+- Use a freshness threshold consistent with kill feed freshness logic.
+- If no current/open event window exists, return empty items with a clear scope.
+
+### 7. RCON player-list data
+
+If a real RCON player-list command exists:
+
+- Prefer it for live roster.
+- Combine it with AdminLog-derived kills/deaths only when safe.
+- Mark fields not supported by RCON as null.
+- Do not block the endpoint indefinitely on RCON timeouts.
+
+### 8. Frontend rendering
+
+Replace the static "Estadísticas en vivo" placeholder with dynamic rendering.
+
+If items exist:
+
+- Show a compact table or cards with player stats.
+- Sort by kills descending, then deaths ascending, then name.
+- Indicate if stats are partial.
+
+If items are empty, show:
+
+- "Todavía no hay estadísticas fiables de jugadores para esta partida."
+
+If data is partial, show:
+
+- "Estadísticas parciales derivadas de eventos recientes."
+
+### 9. Frontend polling
+
+- Poll player stats with the current-match page refresh cycle or a safe separate interval.
+- Avoid overlapping requests.
+- Do not flicker table rows on every poll.
+- Avoid duplicate player rows.
+
+### 10. Tests
+
+Add focused backend tests for:
+
+- unsupported server rejection
+- empty current stats response
+- AdminLog-derived player aggregation if used
+- stale event filtering
+- no raw AdminLog exposure
+- stable sorting
+- partial confidence metadata
+
+## Validation
+
+Run:
+
+- `python -m compileall backend/app`
+- focused backend tests if available
+- `node --check frontend/assets/js/partida-actual.js`
+
+## Manual verification checklist
+
+- Call `Invoke-RestMethod "http://localhost:8000/api/current-match/players?server=comunidad-hispana-01" | ConvertTo-Json -Depth 20`.
+- Call `Invoke-RestMethod "http://localhost:8000/api/current-match/players?server=comunidad-hispana-02" | ConvertTo-Json -Depth 20`.
+- Verify unsupported server values are rejected safely.
+- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-01`.
+- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-02`.
+- Verify the "Estadísticas en vivo" section no longer remains permanently empty when current reliable/partial player data exists.
+- Verify the UI clearly says when stats are partial or unavailable.
+- Verify no fake players are shown.
+- Verify no raw AdminLog lines are shown.
+- Verify existing historical detail stats still work.
+
+## Expected outcome
+
+The current-match page can show live or partial player statistics when supported by reliable/current data, and otherwise shows a clear honest empty state.
+
+## AI Platform lifecycle
+
+After implementation and validation:
+
+- Move this task according to the lifecycle defined in `AGENTS.md`.
+- Do not mark unrelated tasks as done automatically.
+
+## Outcome
+
+### RCA and source choice
+
+- `RCON getSession` remains available for the current-match header but its current player count is explicitly marked `rcon-session-unverified`.
+- No dedicated RCON player-list wrapper or route exists in the current `rcon_client` implementation.
+- AdminLog kill events already expose safe normalized player names, teams, weapons and current-match window/freshness handling through the kill-feed read model.
+- The historical AdminLog materializer derives player combat stats from the same kind of kill events for closed matches.
+- Chosen source: current/fresh normalized AdminLog kill rows. The new current-match player projection is intentionally `event-derived-partial`, not a complete live roster.
+
+### Change summary
+
+- Added `GET /api/current-match/players?server=...` for trusted active current-match servers only.
+- Added a safe AdminLog-derived player aggregator with kills, deaths, teamkills, teamkill deaths, team, last-seen timestamp and favorite weapon when observed.
+- Kept stale fallback filtering aligned with the current kill feed and kept raw AdminLog messages out of the payload.
+- Replaced the static current-match player placeholder with a polled partial-stat table and an honest empty state.
+
+### Validation
+
+- Ran `python -m compileall backend/app`.
+- Ran `node --check frontend/assets/js/partida-actual.js`.
+- Attempted `python -m pytest backend/tests/test_current_match_payload.py backend/tests/test_rcon_admin_log_storage.py`; the active Python environment does not have `pytest` installed.
+- Ran a direct Python smoke harness for trusted/unsupported route resolution, AdminLog aggregation, teamkill handling, stale filtering and raw-log exclusion.
+- Ran `scripts/run-historical-ui-regression-tests.ps1`.
+- Ran `git diff --check` and `git diff --name-only`.
+- Task-specific changed product files are `backend/app/routes.py`, `backend/app/payloads.py`, `backend/app/rcon_admin_log_storage.py`, `backend/tests/test_current_match_payload.py` and `frontend/assets/js/partida-actual.js`. The current worktree also contains `frontend/assets/css/historico.css` from completed `TASK-154`.
+- Rendered Browser QA remains to be repeated when the Browser automation entry point is exposed in-session.
diff --git a/backend/app/payloads.py b/backend/app/payloads.py
index de029a8..adb7079 100644
--- a/backend/app/payloads.py
+++ b/backend/app/payloads.py
@@ -50,7 +50,7 @@ from .historical_storage import (
from .rcon_historical_read_model import get_rcon_historical_match_detail
from .normalizers import normalize_map_name
from .rcon_client import load_rcon_targets, query_live_server_sample
-from .rcon_admin_log_storage import list_current_match_kill_feed
+from .rcon_admin_log_storage import list_current_match_kill_feed, list_current_match_player_stats
from .scoreboard_origins import get_trusted_public_scoreboard_origin
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
@@ -370,6 +370,22 @@ def build_current_match_kill_feed_payload(
}
+def build_current_match_player_stats_payload(*, server_slug: str) -> dict[str, object]:
+ """Return current player stats only when safe AdminLog evidence exists."""
+ origin = get_trusted_public_scoreboard_origin(server_slug)
+ if origin is None:
+ raise ValueError("Unsupported current match server.")
+ stats = list_current_match_player_stats(server_key=origin.slug)
+ return {
+ "status": "ok",
+ "data": {
+ "server_slug": origin.slug,
+ "server_name": origin.display_name,
+ **stats,
+ },
+ }
+
+
def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None:
"""Read one configured trusted RCON target for the current-match view."""
try:
diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py
index 3c4a99b..c5c2e6a 100644
--- a/backend/app/rcon_admin_log_storage.py
+++ b/backend/app/rcon_admin_log_storage.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import re
import sqlite3
+from collections import Counter
from collections.abc import Mapping
from contextlib import closing
from datetime import datetime, timedelta, timezone
@@ -422,6 +423,80 @@ def list_current_match_kill_feed(
}
+def list_current_match_player_stats(
+ *,
+ server_key: str,
+ db_path: Path | None = None,
+ now: datetime | None = None,
+) -> dict[str, object]:
+ """Return partial current player stats derived from safe AdminLog kill rows."""
+ feed = list_current_match_kill_feed(
+ server_key=server_key,
+ limit=100,
+ db_path=db_path,
+ now=now,
+ )
+ players: dict[str, dict[str, object]] = {}
+ weapon_counts: dict[str, Counter[str]] = {}
+ for item in feed["items"]:
+ if not isinstance(item, Mapping):
+ continue
+ killer = _ensure_current_match_player(
+ players,
+ item.get("killer_name"),
+ team=item.get("killer_team"),
+ event_timestamp=item.get("event_timestamp"),
+ )
+ victim = _ensure_current_match_player(
+ players,
+ item.get("victim_name"),
+ team=item.get("victim_team"),
+ event_timestamp=item.get("event_timestamp"),
+ )
+ if killer is not None:
+ weapon = _safe_event_field(item.get("weapon")) or "UNKNOWN"
+ weapon_counts.setdefault(str(killer["player_name"]), Counter())[weapon] += 1
+ if item.get("is_teamkill"):
+ killer["teamkills"] = int(killer["teamkills"]) + 1
+ else:
+ killer["kills"] = int(killer["kills"]) + 1
+ if victim is not None:
+ victim["deaths"] = int(victim["deaths"]) + 1
+ if item.get("is_teamkill"):
+ victim["deaths_by_teamkill"] = int(victim["deaths_by_teamkill"]) + 1
+
+ items = []
+ for player in players.values():
+ items.append(
+ {
+ **player,
+ "favorite_weapon": _favorite_weapon_for_player(
+ weapon_counts.get(str(player["player_name"]))
+ ),
+ "source": "rcon-admin-log-kill-events",
+ "confidence": "event-derived-partial",
+ }
+ )
+ items.sort(
+ key=lambda player: (
+ -int(player["kills"]),
+ int(player["deaths"]),
+ str(player["player_name"]).casefold(),
+ )
+ )
+ return {
+ "scope": feed["scope"],
+ "confidence": "event-derived-partial" if items else feed["confidence"],
+ "source": "rcon-admin-log-kill-events",
+ "updated_at": max(
+ (str(item["last_seen_at"]) for item in items if item.get("last_seen_at")),
+ default=None,
+ ),
+ "stale_events_filtered": feed["stale_events_filtered"],
+ "items": items,
+ }
+
+
def get_latest_rcon_player_profile_summaries(
*,
target_key: str,
@@ -543,6 +618,45 @@ def _safe_event_field(value: object) -> str | None:
return normalized or None
+def _ensure_current_match_player(
+ players: dict[str, dict[str, object]],
+ player_name: object,
+ *,
+ team: object,
+ event_timestamp: object,
+) -> dict[str, object] | None:
+ safe_name = _safe_event_field(player_name)
+ if safe_name is None:
+ return None
+ player = players.setdefault(
+ safe_name,
+ {
+ "player_name": safe_name,
+ "team": None,
+ "kills": 0,
+ "deaths": 0,
+ "teamkills": 0,
+ "deaths_by_teamkill": 0,
+ "last_seen_at": None,
+ },
+ )
+ safe_team = _safe_event_field(team)
+ if safe_team:
+ player["team"] = safe_team
+ safe_timestamp = _safe_event_field(event_timestamp)
+ if safe_timestamp and (
+ player["last_seen_at"] is None or safe_timestamp > str(player["last_seen_at"])
+ ):
+ player["last_seen_at"] = safe_timestamp
+ return player
+
+
+def _favorite_weapon_for_player(weapons: Counter[str] | None) -> str | None:
+ if not weapons:
+ return None
+ return min(weapons.items(), key=lambda item: (-item[1], item[0]))[0]
+
+
def _row_is_current_match_fallback_fresh(
row: Mapping[str, object],
freshness_anchor: datetime,
diff --git a/backend/app/routes.py b/backend/app/routes.py
index c1dea5d..6e6297d 100644
--- a/backend/app/routes.py
+++ b/backend/app/routes.py
@@ -8,6 +8,7 @@ from urllib.parse import parse_qs, urlparse
from .payloads import (
build_community_payload,
build_current_match_kill_feed_payload,
+ build_current_match_player_stats_payload,
build_current_match_payload,
build_discord_payload,
build_elo_mmr_leaderboard_payload,
@@ -85,6 +86,14 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
limit=limit,
)
+ if parsed.path == "/api/current-match/players":
+ server_slug = parse_qs(parsed.query).get("server", [None])[0]
+ if not server_slug:
+ return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
+ if get_trusted_public_scoreboard_origin(server_slug) is None:
+ return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported")
+ return HTTPStatus.OK, build_current_match_player_stats_payload(server_slug=server_slug)
+
if parsed.path == "/api/historical/weekly-top-kills":
limit = _parse_limit(parsed.query)
if limit is None:
diff --git a/backend/tests/test_current_match_payload.py b/backend/tests/test_current_match_payload.py
index 8798387..abf3a1b 100644
--- a/backend/tests/test_current_match_payload.py
+++ b/backend/tests/test_current_match_payload.py
@@ -1,7 +1,9 @@
from http import HTTPStatus
+from datetime import datetime, timezone
from unittest.mock import patch
from app.payloads import build_current_match_payload
+from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries
from app.rcon_client import RconServerTarget
from app.routes import resolve_get_payload
@@ -109,6 +111,104 @@ def test_current_match_route_rejects_unsupported_server():
assert payload["status"] == "error"
+def test_current_match_player_route_rejects_unsupported_server():
+ status, payload = resolve_get_payload("/api/current-match/players?server=not-trusted")
+
+ assert status == HTTPStatus.NOT_FOUND
+ assert payload["status"] == "error"
+
+
+def test_current_match_player_stats_aggregate_safe_admin_log_rows(tmp_path):
+ db_path = tmp_path / "admin-log.sqlite3"
+ persist_rcon_admin_log_entries(
+ target={
+ "target_key": "comunidad-hispana-01",
+ "external_server_id": "comunidad-hispana-01",
+ },
+ entries=[
+ {
+ "timestamp": "2026-05-21T10:00:00Z",
+ "message": "[1:00 min (100)] MATCH START Mortain Warfare",
+ },
+ {
+ "timestamp": "2026-05-21T10:01:00Z",
+ "message": (
+ "[2:00 min (120)] KILL: Bravo(Axis/steam-bravo) -> "
+ "Alpha(Allies/steam-alpha) with MP40"
+ ),
+ },
+ {
+ "timestamp": "2026-05-21T10:02:00Z",
+ "message": (
+ "[3:00 min (140)] KILL: Alpha(Allies/steam-alpha) -> "
+ "Charlie(Allies/steam-charlie) with M1 GARAND"
+ ),
+ },
+ {
+ "timestamp": "2026-05-21T10:03:00Z",
+ "message": (
+ "[4:00 min (160)] KILL: Alpha(Allies/steam-alpha) -> "
+ "Bravo(Axis/steam-bravo) with M1 GARAND"
+ ),
+ },
+ ],
+ db_path=db_path,
+ )
+
+ stats = list_current_match_player_stats(
+ server_key="comunidad-hispana-01",
+ db_path=db_path,
+ )
+
+ assert stats["scope"] == "open-admin-log-match-window"
+ assert stats["confidence"] == "event-derived-partial"
+ assert stats["source"] == "rcon-admin-log-kill-events"
+ assert [item["player_name"] for item in stats["items"]] == ["Alpha", "Bravo", "Charlie"]
+ assert stats["items"][0] == {
+ "player_name": "Alpha",
+ "team": "Allies",
+ "kills": 1,
+ "deaths": 1,
+ "teamkills": 1,
+ "deaths_by_teamkill": 0,
+ "last_seen_at": "2026-05-21T10:03:00Z",
+ "favorite_weapon": "M1 GARAND",
+ "source": "rcon-admin-log-kill-events",
+ "confidence": "event-derived-partial",
+ }
+ assert "raw_message" not in stats["items"][0]
+
+
+def test_current_match_player_stats_filter_stale_recent_events(tmp_path):
+ db_path = tmp_path / "admin-log.sqlite3"
+ persist_rcon_admin_log_entries(
+ target={
+ "target_key": "comunidad-hispana-01",
+ "external_server_id": "comunidad-hispana-01",
+ },
+ entries=[
+ {
+ "timestamp": "2026-05-21T09:30:00Z",
+ "message": (
+ "[1:00 min (1779355800)] KILL: Old Killer(Allies/steam-old) -> "
+ "Old Victim(Axis/steam-victim-old) with M1 GARAND"
+ ),
+ }
+ ],
+ db_path=db_path,
+ )
+
+ stats = list_current_match_player_stats(
+ server_key="comunidad-hispana-01",
+ db_path=db_path,
+ now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc),
+ )
+
+ assert stats["scope"] == "no-current-match-events"
+ assert stats["confidence"] == "stale-filtered"
+ assert stats["items"] == []
+
+
def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
with (
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),
diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css
index 52e26b6..8fe1a73 100644
--- a/frontend/assets/css/historico.css
+++ b/frontend/assets/css/historico.css
@@ -589,6 +589,118 @@
gap: 14px;
}
+.current-match-killfeed {
+ display: grid;
+ grid-auto-flow: column;
+ grid-template-rows: repeat(5, minmax(0, 1fr));
+ grid-auto-columns: minmax(0, 1fr);
+ gap: 10px 12px;
+ min-height: 250px;
+ max-height: 320px;
+ padding: 16px;
+ overflow: hidden;
+ border: 1px solid rgba(210, 182, 118, 0.26);
+ border-radius: 8px;
+ background:
+ linear-gradient(90deg, rgba(210, 182, 118, 0.08) 1px, transparent 1px),
+ linear-gradient(180deg, rgba(183, 201, 125, 0.06) 1px, transparent 1px),
+ linear-gradient(180deg, rgba(12, 18, 11, 0.96), rgba(7, 10, 7, 0.98));
+ background-size: 33.333% 100%, 100% 62px, auto;
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.04),
+ 0 18px 38px rgba(0, 0, 0, 0.22);
+}
+
+.current-match-killfeed__row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 8px;
+ min-height: 38px;
+ padding: 7px 9px;
+ overflow: hidden;
+ border: 1px solid rgba(159, 168, 141, 0.16);
+ border-radius: 6px;
+ color: var(--text);
+ background: rgba(24, 31, 21, 0.86);
+ animation: current-match-killfeed-enter 180ms ease-out;
+}
+
+.current-match-killfeed__row.is-teamkill {
+ border-color: rgba(210, 182, 118, 0.42);
+ background: rgba(73, 49, 27, 0.72);
+}
+
+.current-match-killfeed__player {
+ min-width: 0;
+ overflow: hidden;
+ font-size: 0.84rem;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.current-match-killfeed__player--killer {
+ color: var(--accent-strong);
+}
+
+.current-match-killfeed__player--victim {
+ color: var(--text-soft);
+}
+
+.current-match-killfeed__weapon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ min-width: 34px;
+ min-height: 24px;
+ padding: 0 6px;
+ border: 1px solid rgba(183, 201, 125, 0.24);
+ border-radius: 4px;
+ color: var(--text);
+ background: rgba(7, 10, 7, 0.8);
+ font-size: 0.68rem;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.current-match-killfeed__weapon em {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.current-match-killfeed__teamkill {
+ display: inline-flex;
+ align-items: center;
+ min-height: 20px;
+ padding: 0 5px;
+ border: 1px solid rgba(210, 182, 118, 0.36);
+ border-radius: 4px;
+ color: var(--accent-warm);
+ font-size: 0.62rem;
+ font-weight: 800;
+}
+
+@keyframes current-match-killfeed-enter {
+ from {
+ opacity: 0;
+ transform: translateX(8px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
.historical-comparison-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
@@ -806,6 +918,38 @@
.historical-table {
min-width: 540px;
}
+
+ .current-match-killfeed {
+ grid-template-rows: repeat(8, minmax(0, 1fr));
+ min-height: 312px;
+ max-height: 380px;
+ background-size: 50% 100%, 100% 48px, auto;
+ }
+
+ .current-match-killfeed__row {
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ }
+
+ .current-match-killfeed__teamkill {
+ grid-column: 1 / -1;
+ justify-self: end;
+ }
+}
+
+@media (max-width: 480px) {
+ .current-match-killfeed {
+ grid-auto-flow: row;
+ grid-template-rows: none;
+ min-height: 0;
+ max-height: none;
+ background-size: 100% 48px, 100% 48px, auto;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .current-match-killfeed__row {
+ animation: none;
+ }
}
.historical-match-card--clean {
diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js
index b4fae7a..e00530f 100644
--- a/frontend/assets/js/partida-actual.js
+++ b/frontend/assets/js/partida-actual.js
@@ -7,6 +7,13 @@ const CURRENT_MATCH_SCOREBOARDS = Object.freeze({
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es",
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443",
});
+const CURRENT_MATCH_KILL_FEED_LIMIT = 15;
+const CURRENT_MATCH_WEAPONS = Object.freeze({
+ "m1 garand": { label: "M1 Garand", glyph: "M1" },
+ mp40: { label: "MP40", glyph: "MP" },
+ "m1a1 thompson": { label: "M1A1 Thompson", glyph: "TH" },
+ unknown: { label: "Arma desconocida", glyph: "?" },
+});
document.addEventListener("DOMContentLoaded", () => {
const params = new URLSearchParams(window.location.search);
@@ -20,6 +27,7 @@ document.addEventListener("DOMContentLoaded", () => {
state: document.getElementById("current-match-state"),
grid: document.getElementById("current-match-grid"),
feedTitle: document.getElementById("current-match-feed-title"),
+ playersTitle: document.getElementById("current-match-players-title"),
mapHero: document.getElementById("current-match-map-hero"),
mapImage: document.getElementById("current-match-map-image"),
mapPlaceholder: document.getElementById("current-match-map-placeholder"),
@@ -34,6 +42,7 @@ document.addEventListener("DOMContentLoaded", () => {
nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`;
const killFeedState = initializeKillFeed(nodes);
+ const playerStatsState = initializePlayerStats(nodes);
let refreshInFlight = false;
const refresh = async () => {
if (refreshInFlight) {
@@ -44,6 +53,7 @@ document.addEventListener("DOMContentLoaded", () => {
await Promise.allSettled([
loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }),
loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }),
+ loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }),
]);
} finally {
refreshInFlight = false;
@@ -71,7 +81,7 @@ async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) {
async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) {
try {
const payload = await fetchJson(
- `${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=30`,
+ `${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=${CURRENT_MATCH_KILL_FEED_LIMIT}`,
);
renderKillFeed(payload?.data || {}, nodes, killFeedState);
} catch (error) {
@@ -79,6 +89,17 @@ async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }
}
}
+async function loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }) {
+ try {
+ const payload = await fetchJson(
+ `${backendBaseUrl}/api/current-match/players?server=${encodeURIComponent(serverSlug)}`,
+ );
+ renderPlayerStats(payload?.data || {}, nodes, playerStatsState);
+ } catch (error) {
+ setState(nodes.playerStatsState, "No se pudieron actualizar las estadisticas en vivo.", true);
+ }
+}
+
function renderCurrentMatch(data, nodes) {
const rawServerName = data.server_name || data.server_slug || "Servidor no disponible";
const serverName = formatServerDisplayName(data, rawServerName);
@@ -117,7 +138,11 @@ function initializeKillFeed(nodes) {
Cargando feed de combate...
-
+
`,
);
}
@@ -125,6 +150,27 @@ function initializeKillFeed(nodes) {
nodes.feedList = document.getElementById("current-match-feed-list");
return {
byId: new Map(),
+ visibleSignature: "",
+ };
+}
+
+function initializePlayerStats(nodes) {
+ const shell = nodes.playersTitle?.closest(".panel__shell");
+ if (shell) {
+ shell.insertAdjacentHTML(
+ "beforeend",
+ `
+
+ Cargando estadisticas en vivo...
+
+
+ `,
+ );
+ }
+ nodes.playerStatsState = document.getElementById("current-match-player-stats-state");
+ nodes.playerStatsShell = document.getElementById("current-match-player-stats-shell");
+ return {
+ visibleSignature: "",
};
}
@@ -138,13 +184,21 @@ function renderKillFeed(data, nodes, state) {
state.byId.set(event.event_id, event);
}
});
- const events = [...state.byId.values()].sort(compareKillFeedEvents).slice(0, 30);
+ const events = [...state.byId.values()]
+ .sort(compareKillFeedEvents)
+ .slice(0, CURRENT_MATCH_KILL_FEED_LIMIT);
if (events.length === 0) {
nodes.feedList.innerHTML = "";
- setState(nodes.feedState, "Todavia no se han detectado bajas en esta partida.");
+ state.visibleSignature = "";
+ setState(nodes.feedState, formatKillFeedCoverage(data.scope));
return;
}
- nodes.feedList.innerHTML = events.map(renderKillFeedRow).join("");
+ const visualEvents = [...events].reverse();
+ const visibleSignature = visualEvents.map((event) => event.event_id).join("|");
+ if (visibleSignature !== state.visibleSignature) {
+ nodes.feedList.innerHTML = visualEvents.map(renderKillFeedRow).join("");
+ state.visibleSignature = visibleSignature;
+ }
nodes.feedState.textContent = formatKillFeedCoverage(data.scope);
nodes.feedState.classList.remove("historical-state--error");
}
@@ -159,35 +213,124 @@ function compareKillFeedEvents(left, right) {
}
function renderKillFeedRow(event) {
+ const weapon = resolveKillFeedWeapon(event.weapon);
const teamkillBadge = event.is_teamkill
- ? 'TK'
+ ? 'TK'
: "";
- const eventTime = formatEventTime(event);
return `
-
-
-
-
${escapeHtml(eventTime)}
-
${escapeHtml(event.killer_name || "Jugador no disponible")}
-
- ${teamkillBadge}
-
-
- ${escapeHtml(event.weapon || "Arma no disponible")}
- -> ${escapeHtml(event.victim_name || "Objetivo no disponible")}
-
+
+
+ ${escapeHtml(event.killer_name || "Jugador no disponible")}
+
+
+ ${escapeHtml(weapon.glyph)}
+ ${escapeHtml(weapon.label)}
+
+
+ ${escapeHtml(event.victim_name || "Objetivo no disponible")}
+
+ ${teamkillBadge}
`;
}
-function formatEventTime(event) {
- const timestamp = formatTimestamp(event.event_timestamp);
- if (timestamp !== "No disponible") {
- return timestamp;
+function resolveKillFeedWeapon(value) {
+ const key = normalizeLookupText(value);
+ return CURRENT_MATCH_WEAPONS[key] || {
+ label: String(value || CURRENT_MATCH_WEAPONS.unknown.label),
+ glyph: CURRENT_MATCH_WEAPONS.unknown.glyph,
+ };
+}
+
+function renderPlayerStats(data, nodes, state) {
+ const items = Array.isArray(data.items) ? sortPlayerStats(data.items) : [];
+ if (items.length === 0) {
+ state.visibleSignature = "";
+ nodes.playerStatsShell.innerHTML = "";
+ nodes.playerStatsShell.hidden = true;
+ setState(
+ nodes.playerStatsState,
+ "Todavia no hay estadisticas fiables de jugadores para esta partida.",
+ );
+ return;
}
- return Number.isFinite(Number(event.server_time))
- ? `Tiempo servidor ${Number(event.server_time)}`
- : "Tiempo no disponible";
+ const signature = items
+ .map((item) =>
+ [
+ item.player_name,
+ item.kills,
+ item.deaths,
+ item.teamkills,
+ item.deaths_by_teamkill,
+ item.favorite_weapon,
+ item.last_seen_at,
+ ].join(":"),
+ )
+ .join("|");
+ if (signature !== state.visibleSignature) {
+ nodes.playerStatsShell.innerHTML = renderPlayerStatsTable(items);
+ state.visibleSignature = signature;
+ }
+ nodes.playerStatsShell.hidden = false;
+ setState(nodes.playerStatsState, "Estadisticas parciales derivadas de eventos recientes.");
+}
+
+function sortPlayerStats(items) {
+ return [...items].sort(
+ (left, right) =>
+ toStatNumber(right.kills) - toStatNumber(left.kills) ||
+ toStatNumber(left.deaths) - toStatNumber(right.deaths) ||
+ String(left.player_name || "").localeCompare(String(right.player_name || ""), "es", {
+ sensitivity: "base",
+ }),
+ );
+}
+
+function renderPlayerStatsTable(items) {
+ return `
+
+
+
+ | Jugador |
+ Equipo |
+ Bajas |
+ Muertes |
+ TK |
+ Muertes TK |
+ Arma frecuente |
+
+
+
+ ${items.map(renderPlayerStatsRow).join("")}
+
+
+ `;
+}
+
+function renderPlayerStatsRow(item) {
+ return `
+
+ | ${escapeHtml(item.player_name || "Jugador no disponible")} |
+ ${escapeHtml(item.team || "No disponible")} |
+ ${escapeHtml(formatStatNumber(item.kills))} |
+ ${escapeHtml(formatStatNumber(item.deaths))} |
+ ${escapeHtml(formatStatNumber(item.teamkills))} |
+ ${escapeHtml(formatStatNumber(item.deaths_by_teamkill))} |
+ ${escapeHtml(item.favorite_weapon || "No disponible")} |
+
+ `;
+}
+
+function toStatNumber(value) {
+ return Number.isFinite(Number(value)) ? Number(value) : 0;
+}
+
+function formatStatNumber(value) {
+ return Number.isFinite(Number(value)) ? String(Number(value)) : "0";
}
function renderCompactMeta(label, value) {
@@ -410,6 +553,9 @@ function formatKillFeedCoverage(scope) {
if (scope === "recent-admin-log-window") {
return "Cobertura parcial desde AdminLog reciente.";
}
+ if (scope === "no-current-match-events") {
+ return "Sin bajas recientes asociadas a la partida actual.";
+ }
return "Todavia no se han detectado bajas en esta partida.";
}