feat: add current match killfeed overlay and player stats

This commit is contained in:
devRaGonSa
2026-05-21 18:59:44 +02:00
parent 8a6af3e869
commit 4d7acb6fc5
9 changed files with 1000 additions and 27 deletions

View File

@@ -19,3 +19,4 @@ Date | Task | Duration | Result | Notes
2026-05-19T17:20:44 | worker-cycle | 2240.22 sec | success | codex-runner 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-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-21T15:17:18 | worker-cycle | 688.86 sec | success | codex-runner
2026-05-21T18:59:27 | worker-cycle | 527.54 sec | success | codex-runner

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ from .historical_storage import (
from .rcon_historical_read_model import get_rcon_historical_match_detail from .rcon_historical_read_model import get_rcon_historical_match_detail
from .normalizers import normalize_map_name from .normalizers import normalize_map_name
from .rcon_client import load_rcon_targets, query_live_server_sample 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 .scoreboard_origins import get_trusted_public_scoreboard_origin
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history 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: def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None:
"""Read one configured trusted RCON target for the current-match view.""" """Read one configured trusted RCON target for the current-match view."""
try: try:

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import json import json
import re import re
import sqlite3 import sqlite3
from collections import Counter
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import closing from contextlib import closing
from datetime import datetime, timedelta, timezone 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( def get_latest_rcon_player_profile_summaries(
*, *,
target_key: str, target_key: str,
@@ -543,6 +618,45 @@ def _safe_event_field(value: object) -> str | None:
return normalized or 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( def _row_is_current_match_fallback_fresh(
row: Mapping[str, object], row: Mapping[str, object],
freshness_anchor: datetime, freshness_anchor: datetime,

View File

@@ -8,6 +8,7 @@ from urllib.parse import parse_qs, urlparse
from .payloads import ( from .payloads import (
build_community_payload, build_community_payload,
build_current_match_kill_feed_payload, build_current_match_kill_feed_payload,
build_current_match_player_stats_payload,
build_current_match_payload, build_current_match_payload,
build_discord_payload, build_discord_payload,
build_elo_mmr_leaderboard_payload, build_elo_mmr_leaderboard_payload,
@@ -85,6 +86,14 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
limit=limit, 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": if parsed.path == "/api/historical/weekly-top-kills":
limit = _parse_limit(parsed.query) limit = _parse_limit(parsed.query)
if limit is None: if limit is None:

View File

@@ -1,7 +1,9 @@
from http import HTTPStatus from http import HTTPStatus
from datetime import datetime, timezone
from unittest.mock import patch from unittest.mock import patch
from app.payloads import build_current_match_payload 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.rcon_client import RconServerTarget
from app.routes import resolve_get_payload from app.routes import resolve_get_payload
@@ -109,6 +111,104 @@ def test_current_match_route_rejects_unsupported_server():
assert payload["status"] == "error" 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]: def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
with ( with (
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)), patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),

View File

@@ -589,6 +589,118 @@
gap: 14px; 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 { .historical-comparison-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
@@ -806,6 +918,38 @@
.historical-table { .historical-table {
min-width: 540px; 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 { .historical-match-card--clean {

View File

@@ -7,6 +7,13 @@ const CURRENT_MATCH_SCOREBOARDS = Object.freeze({
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es", "comunidad-hispana-01": "https://scoreboard.comunidadhll.es",
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443", "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", () => { document.addEventListener("DOMContentLoaded", () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -20,6 +27,7 @@ document.addEventListener("DOMContentLoaded", () => {
state: document.getElementById("current-match-state"), state: document.getElementById("current-match-state"),
grid: document.getElementById("current-match-grid"), grid: document.getElementById("current-match-grid"),
feedTitle: document.getElementById("current-match-feed-title"), feedTitle: document.getElementById("current-match-feed-title"),
playersTitle: document.getElementById("current-match-players-title"),
mapHero: document.getElementById("current-match-map-hero"), mapHero: document.getElementById("current-match-map-hero"),
mapImage: document.getElementById("current-match-map-image"), mapImage: document.getElementById("current-match-map-image"),
mapPlaceholder: document.getElementById("current-match-map-placeholder"), mapPlaceholder: document.getElementById("current-match-map-placeholder"),
@@ -34,6 +42,7 @@ document.addEventListener("DOMContentLoaded", () => {
nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`; nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`;
const killFeedState = initializeKillFeed(nodes); const killFeedState = initializeKillFeed(nodes);
const playerStatsState = initializePlayerStats(nodes);
let refreshInFlight = false; let refreshInFlight = false;
const refresh = async () => { const refresh = async () => {
if (refreshInFlight) { if (refreshInFlight) {
@@ -44,6 +53,7 @@ document.addEventListener("DOMContentLoaded", () => {
await Promise.allSettled([ await Promise.allSettled([
loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }), loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }),
loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }), loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }),
loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }),
]); ]);
} finally { } finally {
refreshInFlight = false; refreshInFlight = false;
@@ -71,7 +81,7 @@ async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) {
async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) { async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) {
try { try {
const payload = await fetchJson( 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); renderKillFeed(payload?.data || {}, nodes, killFeedState);
} catch (error) { } 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) { function renderCurrentMatch(data, nodes) {
const rawServerName = data.server_name || data.server_slug || "Servidor no disponible"; const rawServerName = data.server_name || data.server_slug || "Servidor no disponible";
const serverName = formatServerDisplayName(data, rawServerName); const serverName = formatServerDisplayName(data, rawServerName);
@@ -117,7 +138,11 @@ function initializeKillFeed(nodes) {
<p class="historical-state" id="current-match-feed-state" aria-live="polite"> <p class="historical-state" id="current-match-feed-state" aria-live="polite">
Cargando feed de combate... Cargando feed de combate...
</p> </p>
<div class="historical-match-list" id="current-match-feed-list"></div> <section
class="current-match-killfeed"
id="current-match-feed-list"
aria-label="Bajas recientes en la partida actual"
></section>
`, `,
); );
} }
@@ -125,6 +150,27 @@ function initializeKillFeed(nodes) {
nodes.feedList = document.getElementById("current-match-feed-list"); nodes.feedList = document.getElementById("current-match-feed-list");
return { return {
byId: new Map(), byId: new Map(),
visibleSignature: "",
};
}
function initializePlayerStats(nodes) {
const shell = nodes.playersTitle?.closest(".panel__shell");
if (shell) {
shell.insertAdjacentHTML(
"beforeend",
`
<p class="historical-state" id="current-match-player-stats-state" aria-live="polite">
Cargando estadisticas en vivo...
</p>
<div class="historical-table-shell" id="current-match-player-stats-shell" hidden></div>
`,
);
}
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); 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) { if (events.length === 0) {
nodes.feedList.innerHTML = ""; nodes.feedList.innerHTML = "";
setState(nodes.feedState, "Todavia no se han detectado bajas en esta partida."); state.visibleSignature = "";
setState(nodes.feedState, formatKillFeedCoverage(data.scope));
return; 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.textContent = formatKillFeedCoverage(data.scope);
nodes.feedState.classList.remove("historical-state--error"); nodes.feedState.classList.remove("historical-state--error");
} }
@@ -159,35 +213,124 @@ function compareKillFeedEvents(left, right) {
} }
function renderKillFeedRow(event) { function renderKillFeedRow(event) {
const weapon = resolveKillFeedWeapon(event.weapon);
const teamkillBadge = event.is_teamkill const teamkillBadge = event.is_teamkill
? '<span class="status-chip status-chip--fallback">TK</span>' ? '<span class="current-match-killfeed__teamkill">TK</span>'
: ""; : "";
const eventTime = formatEventTime(event);
return ` return `
<article class="historical-match-card"> <article class="current-match-killfeed__row${event.is_teamkill ? " is-teamkill" : ""}">
<div class="historical-match-card__top"> <strong class="current-match-killfeed__player current-match-killfeed__player--killer">
<div class="historical-match-card__title"> ${escapeHtml(event.killer_name || "Jugador no disponible")}
<p>${escapeHtml(eventTime)}</p> </strong>
<strong>${escapeHtml(event.killer_name || "Jugador no disponible")}</strong> <span
</div> class="current-match-killfeed__weapon"
title="${escapeHtml(weapon.label)}"
aria-label="${escapeHtml(weapon.label)}"
>
<span aria-hidden="true">${escapeHtml(weapon.glyph)}</span>
<em>${escapeHtml(weapon.label)}</em>
</span>
<span class="current-match-killfeed__player current-match-killfeed__player--victim">
${escapeHtml(event.victim_name || "Objetivo no disponible")}
</span>
${teamkillBadge} ${teamkillBadge}
</div>
<p>
${escapeHtml(event.weapon || "Arma no disponible")}
-> ${escapeHtml(event.victim_name || "Objetivo no disponible")}
</p>
</article> </article>
`; `;
} }
function formatEventTime(event) { function resolveKillFeedWeapon(value) {
const timestamp = formatTimestamp(event.event_timestamp); const key = normalizeLookupText(value);
if (timestamp !== "No disponible") { return CURRENT_MATCH_WEAPONS[key] || {
return timestamp; 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)) const signature = items
? `Tiempo servidor ${Number(event.server_time)}` .map((item) =>
: "Tiempo no disponible"; [
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 `
<table class="historical-table historical-table--players">
<thead>
<tr>
<th>Jugador</th>
<th>Equipo</th>
<th>Bajas</th>
<th>Muertes</th>
<th>TK</th>
<th>Muertes TK</th>
<th>Arma frecuente</th>
</tr>
</thead>
<tbody>
${items.map(renderPlayerStatsRow).join("")}
</tbody>
</table>
`;
}
function renderPlayerStatsRow(item) {
return `
<tr>
<td>${escapeHtml(item.player_name || "Jugador no disponible")}</td>
<td>${escapeHtml(item.team || "No disponible")}</td>
<td>${escapeHtml(formatStatNumber(item.kills))}</td>
<td>${escapeHtml(formatStatNumber(item.deaths))}</td>
<td>${escapeHtml(formatStatNumber(item.teamkills))}</td>
<td>${escapeHtml(formatStatNumber(item.deaths_by_teamkill))}</td>
<td>${escapeHtml(item.favorite_weapon || "No disponible")}</td>
</tr>
`;
}
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) { function renderCompactMeta(label, value) {
@@ -410,6 +553,9 @@ function formatKillFeedCoverage(scope) {
if (scope === "recent-admin-log-window") { if (scope === "recent-admin-log-window") {
return "Cobertura parcial desde AdminLog reciente."; 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."; return "Todavia no se han detectado bajas en esta partida.";
} }