From 4d7acb6fc5cbfa5119a56695598a83ff1b1c7df2 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 21 May 2026 18:59:44 +0200 Subject: [PATCH] feat: add current match killfeed overlay and player stats --- ai/system-metrics.md | 1 + ...4-current-match-killfeed-overlay-layout.md | 198 ++++++++++++++ ...ASK-155-current-match-live-player-stats.md | 245 ++++++++++++++++++ backend/app/payloads.py | 18 +- backend/app/rcon_admin_log_storage.py | 114 ++++++++ backend/app/routes.py | 9 + backend/tests/test_current_match_payload.py | 100 +++++++ frontend/assets/css/historico.css | 144 ++++++++++ frontend/assets/js/partida-actual.js | 198 ++++++++++++-- 9 files changed, 1000 insertions(+), 27 deletions(-) create mode 100644 ai/tasks/done/TASK-154-current-match-killfeed-overlay-layout.md create mode 100644 ai/tasks/done/TASK-155-current-match-live-player-stats.md 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.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 ` + + + + + + + + + + + + + + ${items.map(renderPlayerStatsRow).join("")} + +
JugadorEquipoBajasMuertesTKMuertes TKArma frecuente
+ `; +} + +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."; }