From 45801bb955203e471e2ee135003c6d8925036b6d Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 21 May 2026 16:39:18 +0200 Subject: [PATCH] fix: project current match live data safely --- ...K-153-fix-current-match-live-projection.md | 110 +++++++++ backend/app/payloads.py | 121 +++++++++ backend/app/rcon_admin_log_storage.py | 45 ++++ backend/tests/test_current_match_payload.py | 118 +++++++++ backend/tests/test_rcon_admin_log_storage.py | 59 +++++ frontend/assets/css/historico.css | 41 ++++ frontend/assets/js/partida-actual.js | 230 +++++++++++++++--- frontend/partida-actual.html | 20 +- 8 files changed, 715 insertions(+), 29 deletions(-) create mode 100644 ai/tasks/done/TASK-153-fix-current-match-live-projection.md create mode 100644 backend/tests/test_current_match_payload.py diff --git a/ai/tasks/done/TASK-153-fix-current-match-live-projection.md b/ai/tasks/done/TASK-153-fix-current-match-live-projection.md new file mode 100644 index 0000000..3df8aa1 --- /dev/null +++ b/ai/tasks/done/TASK-153-fix-current-match-live-projection.md @@ -0,0 +1,110 @@ +--- +id: TASK-153 +title: Fix current match live projection +status: done +type: backend +team: Backend Senior +supporting_teams: + - Frontend Senior +roadmap_item: rcon-full-data +priority: high +--- + +# TASK-153 - Fix current match live projection + +## Goal + +Project trusted live RCON match data onto the current-match page without +misrepresenting player population or stale AdminLog kills as current activity. + +## Context + +`/api/current-match` currently reads through `/api/servers`. The direct RCON +sample already contains `game_mode` and scores, but the server snapshot +projection drops those richer live fields. Direct RCON `playerCount` is also +currently reporting `0` while manual public scoreboard observation shows `1`, +so that population value must be labeled unverified instead of shown as a +confident live count. + +## Steps + +1. Inspect the listed current-match, live RCON and AdminLog files first. +2. Keep the trusted current scoreboard URL mapping and unsupported-server + guards intact. +3. Rework the live payload, kill-feed freshness handling and current-match UI. +4. Add focused backend tests and validate the frontend JavaScript. + +## Files to Read First + +- `AGENTS.md` +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `backend/app/payloads.py` +- `backend/app/rcon_client.py` +- `frontend/assets/js/partida-actual.js` + +## Expected Files to Modify + +- `backend/app/payloads.py` +- `backend/app/rcon_admin_log_storage.py` +- `backend/tests/test_rcon_admin_log_storage.py` +- `backend/tests/test_current_match_payload.py` +- `frontend/partida-actual.html` +- `frontend/assets/js/partida-actual.js` +- current-match styling only where needed + +## Constraints + +- Do not break historical pages or scoreboard correlation logic. +- Do not depend on Comunidad Hispana #03. +- Do not fabricate live data or expose arbitrary public URLs. +- Keep current scoreboard buttons on trusted base URLs without `/games`. +- Preserve null versus explicit zero semantics. +- Do not display closed-match fields or stale kill rows as live data. + +## Validation + +- `python -m compileall backend/app` +- Run focused backend tests for current-match payload and AdminLog kill feed. +- `node --check frontend/assets/js/partida-actual.js` +- Review `git diff --name-only` against this task scope. + +## Outcome + +- `/api/current-match` now queries the requested configured trusted RCON target + for the richer session projection first and falls back to the generic live + server snapshot only when direct RCON data is unavailable. +- RCA: direct RCON `GetServerInformation` samples contain `game_mode`, scores, + map ids and match timing fields. The prior current-match API lost the game + mode and score because it projected through `/api/servers`, whose snapshot + shape keeps only the server-card live fields. +- RCA: live validation on May 21, 2026 still returned RCON `playerCount: 0` + while manual scoreboard observation for the same servers had shown `1`. + Current-match payloads therefore expose RCON population with + `player_count_quality: "rcon-session-unverified"` and the frontend renders + that as `No verificado` instead of a confident `0 / 100`. +- The live page now mirrors the historical detail layout direction: map title, + server context, map art or a clean placeholder, a large in-progress + scoreboard, live metadata only, and trusted base scoreboard buttons. +- AdminLog current-match fallback kills are capped to a conservative 15-minute + freshness window. Old fallback rows now return + `scope: "no-current-match-events"` with stale-filter metadata instead of + leaking into the live feed. +- Added focused tests for direct current-match payload projection and AdminLog + fallback freshness. The repository environment and the rebuilt backend image + do not install `pytest`, so the focused test files could not be executed via + `python -m pytest`; the same payload and freshness paths were exercised with + narrow inline Python scenarios. +- Validation: `python -m compileall backend/app`; + `node --check frontend/assets/js/partida-actual.js`; + `git diff --check`; + `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`; + rebuilt Compose `backend` and `frontend`; REST checks for both trusted + `/api/current-match` endpoints and both kill-feed endpoints; served map asset + checks for Carentan and St. Marie Du Mont; unsupported current-match server + REST check returned 404. + +## Change Budget + +The requested projection spans backend, tests and the live page. Keep changes +focused on current-match files and split unrelated work out. diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 68bd7b9..de029a8 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -49,6 +49,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 .scoreboard_origins import get_trusted_public_scoreboard_origin from .storage import list_latest_snapshots, list_server_history, list_snapshot_history @@ -244,6 +245,61 @@ def build_current_match_payload(*, server_slug: str) -> dict[str, object]: if origin is None: raise ValueError("Unsupported current match server.") + sample = _query_current_match_rcon_sample(origin.slug) + if sample is not None: + normalized = sample["normalized"] + raw_session = sample["raw_session"] + captured_at = _utc_timestamp_now() + map_id = raw_session.get("mapId") or normalized.get("current_map") + map_name = raw_session.get("mapName") or map_id + map_pretty_name = normalize_map_name(map_name) + return { + "status": "ok", + "data": { + "found": True, + "server_slug": origin.slug, + "server_name": normalized.get("server_name") or origin.display_name, + "status": normalized.get("status") or "unavailable", + "map": map_pretty_name, + "map_id": map_id, + "map_pretty_name": map_pretty_name, + "game_mode": normalized.get("game_mode"), + "started_at": None, + "allied_score": normalized.get("allied_score"), + "axis_score": normalized.get("axis_score"), + "allied_players": normalized.get("allied_players"), + "axis_players": normalized.get("axis_players"), + "players": normalized.get("players"), + "max_players": normalized.get("max_players"), + # RCA: getSession currently reports 0 while the public scoreboard + # can show players, so session population is exposed but unverified. + "player_count_quality": ( + "rcon-session-unverified" + if normalized.get("players") is not None + else None + ), + "player_count_source": _source_when_present( + normalized.get("players"), + source="rcon-session", + ), + "score_source": _source_when_present( + normalized.get("allied_score"), + normalized.get("axis_score"), + source="rcon-session", + ), + "map_source": _source_when_present(map_id, map_name, source="rcon-session"), + "match_time_seconds": normalized.get("match_time_seconds"), + "remaining_match_time_seconds": normalized.get( + "remaining_match_time_seconds" + ), + "captured_at": captured_at, + "updated_at": captured_at, + "public_scoreboard_url": origin.base_url, + }, + } + + # The generic live server snapshot is a fallback only. It intentionally + # drops richer RCON session fields such as game mode and current scores. server_payload = build_servers_payload() server_data = server_payload["data"] item = next( @@ -262,12 +318,31 @@ def build_current_match_payload(*, server_slug: str) -> dict[str, object]: "server_name": item.get("server_name") if item else origin.display_name, "status": item.get("status") if item else "unavailable", "map": item.get("current_map") if item else None, + "map_id": None, + "map_pretty_name": item.get("current_map") if item else None, "game_mode": item.get("game_mode") if item else None, "started_at": item.get("started_at") if item else None, "allied_score": item.get("allied_score") if item else None, "axis_score": item.get("axis_score") if item else None, + "allied_players": item.get("allied_players") if item else None, + "axis_players": item.get("axis_players") if item else None, "players": item.get("players") if item else None, "max_players": item.get("max_players") if item else None, + "player_count_quality": _snapshot_player_count_quality(item), + "player_count_source": _snapshot_player_count_source(item), + "score_source": _source_when_present( + item.get("allied_score") if item else None, + item.get("axis_score") if item else None, + source="live-server-snapshot", + ), + "map_source": _source_when_present( + item.get("current_map") if item else None, + source="live-server-snapshot", + ), + "match_time_seconds": item.get("match_time_seconds") if item else None, + "remaining_match_time_seconds": ( + item.get("remaining_match_time_seconds") if item else None + ), "captured_at": item.get("captured_at") if item else None, "updated_at": server_data.get("last_snapshot_at"), "public_scoreboard_url": origin.base_url, @@ -295,6 +370,52 @@ def build_current_match_kill_feed_payload( } +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: + targets = load_rcon_targets() + except (RuntimeError, ValueError): + return None + target = next( + (candidate for candidate in targets if candidate.external_server_id == server_slug), + None, + ) + if target is None: + return None + try: + return query_live_server_sample(target) + except Exception: # noqa: BLE001 - fall back to the existing live snapshot read + return None + + +def _utc_timestamp_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _source_when_present(*values: object, source: str) -> str | None: + return source if any(value is not None for value in values) else None + + +def _snapshot_player_count_quality(item: dict[str, object] | None) -> str | None: + if item is None or item.get("players") is None: + return None + if item.get("snapshot_origin") == "real-rcon": + return "rcon-session-unverified" + if item.get("snapshot_origin") == "real-a2s": + return "a2s-query" + return "snapshot-unverified" + + +def _snapshot_player_count_source(item: dict[str, object] | None) -> str | None: + if item is None or item.get("players") is None: + return None + if item.get("snapshot_origin") == "real-rcon": + return "rcon-session" + if item.get("snapshot_origin") == "real-a2s": + return "a2s" + return "live-server-snapshot" + + def build_error_payload(message: str) -> dict[str, str]: """Return the shared error payload shape used by the backend bootstrap.""" return { diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py index 6672f54..3c4a99b 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -7,6 +7,7 @@ import re import sqlite3 from collections.abc import Mapping from contextlib import closing +from datetime import datetime, timedelta, timezone from pathlib import Path from .config import get_storage_path, use_postgres_rcon_storage @@ -15,6 +16,8 @@ from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot from .rcon_historical_storage import initialize_rcon_historical_storage from .sqlite_utils import connect_sqlite_writer +CURRENT_MATCH_FALLBACK_FRESHNESS = timedelta(minutes=15) + def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path: """Create SQLite structures for parsed RCON AdminLog events.""" @@ -334,6 +337,7 @@ def list_current_match_kill_feed( server_key: str, limit: int = 30, db_path: Path | None = None, + now: datetime | None = None, ) -> dict[str, object]: """Return safe recent kill rows for one AdminLog server window.""" resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) @@ -396,9 +400,24 @@ def list_current_match_kill_feed( scope = "open-admin-log-match-window" confidence = "admin-log-boundary" + stale_events_filtered = 0 + if scope == "recent-admin-log-window": + freshness_anchor = _as_utc_datetime(now) or datetime.now(timezone.utc) + fresh_rows = [ + row + for row in rows + if _row_is_current_match_fallback_fresh(row, freshness_anchor) + ] + stale_events_filtered = len(rows) - len(fresh_rows) + rows = fresh_rows + if not rows: + scope = "no-current-match-events" + confidence = "stale-filtered" if stale_events_filtered else "none" + return { "scope": scope, "confidence": confidence, + "stale_events_filtered": stale_events_filtered, "items": [_serialize_kill_feed_row(row) for row in rows], } @@ -524,6 +543,32 @@ def _safe_event_field(value: object) -> str | None: return normalized or None +def _row_is_current_match_fallback_fresh( + row: Mapping[str, object], + freshness_anchor: datetime, +) -> bool: + event_time = _as_utc_datetime(row["event_timestamp"]) + if event_time is None: + return False + age = freshness_anchor - event_time + return timedelta(0) <= age <= CURRENT_MATCH_FALLBACK_FRESHNESS + + +def _as_utc_datetime(value: object) -> datetime | None: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + else: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + def _persist_rcon_admin_log_entries_postgres( *, target: Mapping[str, object], diff --git a/backend/tests/test_current_match_payload.py b/backend/tests/test_current_match_payload.py new file mode 100644 index 0000000..8798387 --- /dev/null +++ b/backend/tests/test_current_match_payload.py @@ -0,0 +1,118 @@ +from http import HTTPStatus +from unittest.mock import patch + +from app.payloads import build_current_match_payload +from app.rcon_client import RconServerTarget +from app.routes import resolve_get_payload + + +TARGET = RconServerTarget( + name="Comunidad Hispana #01", + host="127.0.0.1", + port=7779, + password="test-password", + source_name="test-rcon", + external_server_id="comunidad-hispana-01", +) + + +def test_current_match_payload_projects_rich_live_rcon_session_fields(): + data = _build_with_rcon_sample( + { + "normalized": { + "server_name": "Comunidad Hispana #01", + "status": "online", + "current_map": "carentan_warfare", + "game_mode": "Warfare", + "allied_score": 2, + "axis_score": 2, + "allied_players": 0, + "axis_players": 0, + "players": 0, + "max_players": 100, + "match_time_seconds": 5400, + "remaining_match_time_seconds": 0, + }, + "raw_session": {"mapId": "carentan_warfare", "mapName": "CARENTAN"}, + } + ) + + assert data["map"] == "Carentan" + assert data["map_id"] == "carentan_warfare" + assert data["map_pretty_name"] == "Carentan" + assert data["game_mode"] == "Warfare" + assert data["allied_score"] == 2 + assert data["axis_score"] == 2 + assert data["players"] == 0 + assert data["player_count_quality"] == "rcon-session-unverified" + assert data["player_count_source"] == "rcon-session" + assert data["score_source"] == "rcon-session" + assert data["map_source"] == "rcon-session" + assert data["public_scoreboard_url"] == "https://scoreboard.comunidadhll.es" + assert "/games" not in data["public_scoreboard_url"] + + +def test_current_match_payload_preserves_missing_values_as_null(): + data = _build_with_rcon_sample( + { + "normalized": { + "server_name": "Comunidad Hispana #01", + "status": "online", + "current_map": None, + "game_mode": None, + "players": None, + "max_players": None, + }, + "raw_session": {}, + } + ) + + assert data["map"] is None + assert data["map_id"] is None + assert data["game_mode"] is None + assert data["allied_score"] is None + assert data["axis_score"] is None + assert data["players"] is None + assert data["player_count_quality"] is None + assert data["player_count_source"] is None + assert data["score_source"] is None + assert data["map_source"] is None + + +def test_current_match_payload_keeps_explicit_zero_score(): + data = _build_with_rcon_sample( + { + "normalized": { + "server_name": "Comunidad Hispana #01", + "status": "online", + "current_map": "stmariedumont_warfare", + "allied_score": 0, + "axis_score": 0, + }, + "raw_session": { + "mapId": "stmariedumont_warfare", + "mapName": "ST MARIE DU MONT", + }, + } + ) + + assert data["map"] == "St. Marie Du Mont" + assert data["allied_score"] == 0 + assert data["axis_score"] == 0 + assert data["score_source"] == "rcon-session" + + +def test_current_match_route_rejects_unsupported_server(): + status, payload = resolve_get_payload("/api/current-match?server=not-trusted") + + assert status == HTTPStatus.NOT_FOUND + assert payload["status"] == "error" + + +def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]: + with ( + patch("app.payloads.load_rcon_targets", return_value=(TARGET,)), + patch("app.payloads.query_live_server_sample", return_value=sample), + ): + payload = build_current_match_payload(server_slug="comunidad-hispana-01") + return payload["data"] diff --git a/backend/tests/test_rcon_admin_log_storage.py b/backend/tests/test_rcon_admin_log_storage.py index 8848a81..4b44fc9 100644 --- a/backend/tests/test_rcon_admin_log_storage.py +++ b/backend/tests/test_rcon_admin_log_storage.py @@ -1,6 +1,7 @@ import gc import json import sqlite3 +from datetime import datetime, timezone from app.rcon_admin_log_storage import ( initialize_rcon_admin_log_storage, @@ -309,3 +310,61 @@ def test_current_match_kill_feed_prefers_open_match_window_and_normalizes_rows(t "is_teamkill": True, } gc.collect() + + +def test_current_match_kill_feed_filters_stale_recent_fallback_rows(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + 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, + ) + + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc), + ) + + assert feed["scope"] == "no-current-match-events" + assert feed["confidence"] == "stale-filtered" + assert feed["stale_events_filtered"] == 1 + assert feed["items"] == [] + gc.collect() + + +def test_current_match_kill_feed_marks_fresh_recent_fallback_rows_partial(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-21T09:50:00Z", + "message": ( + "[1:00 min (1779357000)] KILL: Fresh Killer(Allies/steam-fresh) -> " + "Fresh Victim(Axis/steam-victim-fresh) with M1 GARAND" + ), + } + ], + db_path=db_path, + ) + + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc), + ) + + assert feed["scope"] == "recent-admin-log-window" + assert feed["confidence"] == "partial" + assert feed["stale_events_filtered"] == 0 + assert [item["killer_name"] for item in feed["items"]] == ["Fresh Killer"] + gc.collect() diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index 98e3157..52e26b6 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -88,6 +88,47 @@ object-fit: cover; } +.current-match-map-hero { + display: grid; + isolation: isolate; +} + +.current-match-map-hero > * { + grid-area: 1 / 1; +} + +.current-match-map-placeholder { + z-index: 0; + display: grid; + align-content: end; + gap: 6px; + min-height: 220px; + padding: 24px; + color: var(--text); + background: + linear-gradient(135deg, rgba(183, 201, 125, 0.12), transparent 44%), + repeating-linear-gradient( + -28deg, + rgba(210, 182, 118, 0.06) 0 1px, + transparent 1px 20px + ); +} + +.current-match-map-placeholder strong { + font-size: 1.16rem; + text-transform: uppercase; +} + +.current-match-map-placeholder span { + color: var(--text-soft); +} + +.current-match-scoreboard-message { + max-width: 12ch; + font-size: clamp(2rem, 4vw, 3.3rem); + line-height: 1; +} + .historical-content { gap: 22px; } diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js index 3b2f8c7..608a69e 100644 --- a/frontend/assets/js/partida-actual.js +++ b/frontend/assets/js/partida-actual.js @@ -3,6 +3,10 @@ const CURRENT_MATCH_SERVERS = Object.freeze({ "comunidad-hispana-01": "Comunidad Hispana #01", "comunidad-hispana-02": "Comunidad Hispana #02", }); +const CURRENT_MATCH_SCOREBOARDS = Object.freeze({ + "comunidad-hispana-01": "https://scoreboard.comunidadhll.es", + "comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443", +}); document.addEventListener("DOMContentLoaded", () => { const params = new URLSearchParams(window.location.search); @@ -16,6 +20,9 @@ document.addEventListener("DOMContentLoaded", () => { state: document.getElementById("current-match-state"), grid: document.getElementById("current-match-grid"), feedTitle: document.getElementById("current-match-feed-title"), + mapHero: document.getElementById("current-match-map-hero"), + mapImage: document.getElementById("current-match-map-image"), + mapPlaceholder: document.getElementById("current-match-map-placeholder"), }; const backendBaseUrl = document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; @@ -74,24 +81,17 @@ async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState } function renderCurrentMatch(data, nodes) { const serverName = data.server_name || data.server_slug || "Servidor no disponible"; - const mapName = data.map || "Mapa no disponible"; - nodes.title.textContent = serverName; - nodes.summary.textContent = mapName; + const mapName = data.map_pretty_name || data.map || "Mapa no disponible"; + const scoreboardUrl = resolveTrustedScoreboardUrl(data); + nodes.title.textContent = mapName; + nodes.summary.textContent = serverName; nodes.note.textContent = data.found - ? "Snapshot en vivo recibido. La pagina se actualiza cada 30 segundos." + ? "Lectura en vivo recibida. La pagina se actualiza cada 30 segundos." : "Todavia no hay snapshot live disponible para este servidor."; - nodes.scoreboard.href = data.public_scoreboard_url; - nodes.scoreboard.hidden = !data.public_scoreboard_url; - nodes.grid.innerHTML = [ - renderStat("Estado", formatStatus(data.status)), - renderStat("Mapa", mapName), - renderStat("Modo", data.game_mode || "No disponible"), - renderStat("Inicio", formatTimestamp(data.started_at)), - renderStat("Jugadores", formatPlayers(data.players, data.max_players)), - renderStat("Marcador aliado", formatScore(data.allied_score)), - renderStat("Marcador eje", formatScore(data.axis_score)), - renderStat("Actualizado", formatTimestamp(data.captured_at || data.updated_at)), - ].join(""); + nodes.scoreboard.href = scoreboardUrl || "./index.html"; + nodes.scoreboard.hidden = !scoreboardUrl; + renderMapHero(data, mapName, nodes); + nodes.grid.innerHTML = renderLiveScoreboard(data, { mapName, serverName }); nodes.state.hidden = true; nodes.grid.hidden = false; } @@ -103,6 +103,7 @@ function renderUnsupportedServer(nodes) { nodes.note.textContent = ""; nodes.scoreboard.hidden = true; nodes.grid.hidden = true; + renderMapHero({}, "Mapa no disponible", nodes); setState(nodes.state, "No se puede consultar la partida solicitada.", true); } @@ -128,6 +129,9 @@ function initializeKillFeed(nodes) { function renderKillFeed(data, nodes, state) { const incoming = Array.isArray(data.items) ? data.items : []; + if (data.scope === "no-current-match-events") { + state.byId.clear(); + } incoming.forEach((event) => { if (event?.event_id) { state.byId.set(event.event_id, event); @@ -140,10 +144,7 @@ function renderKillFeed(data, nodes, state) { return; } nodes.feedList.innerHTML = events.map(renderKillFeedRow).join(""); - nodes.feedState.textContent = - data.scope === "open-admin-log-match-window" - ? "Bajas del tramo abierto detectado por AdminLog." - : "Bajas recientes de AdminLog con cobertura parcial."; + nodes.feedState.textContent = formatKillFeedCoverage(data.scope); nodes.feedState.classList.remove("historical-state--error"); } @@ -188,10 +189,10 @@ function formatEventTime(event) { : "Tiempo no disponible"; } -function renderStat(label, value) { +function renderCompactMeta(label, value) { return ` -
-

${escapeHtml(label)}

+
+ ${escapeHtml(label)} ${escapeHtml(value)}
`; @@ -208,16 +209,12 @@ function formatStatus(value) { } function formatPlayers(players, maxPlayers) { - if (!Number.isFinite(Number(players)) || !Number.isFinite(Number(maxPlayers))) { + if (!isNumericValue(players) || !isNumericValue(maxPlayers)) { return "No disponible"; } return `${Number(players)} / ${Number(maxPlayers)}`; } -function formatScore(value) { - return Number.isFinite(Number(value)) ? String(Number(value)) : "No disponible"; -} - function formatTimestamp(value) { if (!value) { return "No disponible"; @@ -232,6 +229,183 @@ function formatTimestamp(value) { }).format(timestamp); } +function renderLiveScoreboard(data, { mapName, serverName }) { + const scoreKnown = hasKnownScore(data); + const scoreMarkup = scoreKnown + ? `${Number(data.allied_score)} : ${Number(data.axis_score)}` + : "Marcador no disponible"; + const scoreClass = scoreKnown ? "" : " current-match-scoreboard-message"; + const metadata = [ + ["Servidor", serverName], + ["Mapa", mapName], + ["Modo", formatGameMode(data.game_mode)], + ]; + if (data.started_at) { + metadata.push(["Inicio", formatTimestamp(data.started_at)]); + } + const remainingTime = Number(data.remaining_match_time_seconds); + if (Number.isFinite(remainingTime) && remainingTime > 0) { + metadata.push(["Tiempo restante", formatDuration(remainingTime)]); + } + const matchTime = Number(data.match_time_seconds); + if (Number.isFinite(matchTime) && matchTime > 0) { + metadata.push(["Tiempo de partida", formatDuration(matchTime)]); + } + metadata.push(["Jugadores", formatPlayerCount(data)]); + metadata.push(["Actualizado", formatTimestamp(data.captured_at || data.updated_at)]); + + return ` +
+
+ ${renderLiveSide("historical-scoreboard-side--allied", "Aliados", "./assets/img/factions/us.webp")} +
+ ${escapeHtml(formatStatus(data.status))} + ${escapeHtml(scoreMarkup)} + ${escapeHtml(mapName)} + ${escapeHtml(formatGameMode(data.game_mode))} +
+ ${renderLiveSide("historical-scoreboard-side--axis", "Eje", "./assets/img/factions/germany.webp")} +
+
+ ${metadata.map(([label, value]) => renderCompactMeta(label, value)).join("")} +
+
+ `; +} + +function renderLiveSide(sideClass, label, emblem) { + return ` +
+ ${escapeHtml(label)} +
+ ${escapeHtml(label)} +
+
+ `; +} + +function renderMapHero(data, mapName, nodes) { + if (!nodes.mapImage || !nodes.mapPlaceholder) { + return; + } + const mapImagePath = resolveMapImagePath(data, mapName); + nodes.mapPlaceholder.hidden = Boolean(mapImagePath); + nodes.mapImage.hidden = !mapImagePath; + if (!mapImagePath) { + nodes.mapImage.removeAttribute("src"); + nodes.mapImage.alt = ""; + return; + } + nodes.mapImage.src = mapImagePath; + nodes.mapImage.alt = mapName; + nodes.mapImage.onerror = () => { + nodes.mapImage.removeAttribute("src"); + nodes.mapImage.hidden = true; + nodes.mapPlaceholder.hidden = false; + }; +} + +function resolveMapImagePath(data, mapName) { + const normalizedMap = normalizeLookupText( + `${data.map_id || ""} ${data.map || ""} ${data.map_pretty_name || ""} ${mapName || ""}`, + ).replaceAll(" ", ""); + const mapAssetByKey = { + carentan: "carentan-day.webp", + driel: "driel-day.webp", + elalamein: "elalamein-day.webp", + elsenbornridge: "elsenbornridge-day.webp", + foy: "foy-day.webp", + hill400: "hill400-day.webp", + hurtgenforest: "hurtgenforest-day.webp", + kharkov: "kharkov-day.webp", + kursk: "kursk-day.webp", + mortain: "mortain-day.webp", + omahabeach: "omahabeach-day.webp", + purpleheartlane: "purpleheartlane-rain.webp", + smolensk: "smolensk-day.webp", + stmariedumont: "stmariedumont-day.webp", + stmereeglise: "stmereeglise-day.webp", + tobrukdawn: "tobruk-dawn.webp", + tobruk: "tobruk-day.webp", + utahbeach: "utahbeach-day.webp", + }; + const matchedKey = Object.keys(mapAssetByKey).find((key) => + normalizedMap.includes(key), + ); + return matchedKey ? `./assets/img/maps/${mapAssetByKey[matchedKey]}` : ""; +} + +function resolveTrustedScoreboardUrl(data) { + const trustedUrl = CURRENT_MATCH_SCOREBOARDS[data.server_slug]; + return data.public_scoreboard_url === trustedUrl ? trustedUrl : ""; +} + +function hasKnownScore(data) { + return isNumericValue(data.allied_score) && isNumericValue(data.axis_score); +} + +function formatPlayerCount(data) { + if (!isReliablePlayerCount(data.player_count_quality)) { + return "No verificado"; + } + return formatPlayers(data.players, data.max_players); +} + +function isReliablePlayerCount(quality) { + return quality === "reliable" || quality === "a2s-query"; +} + +function isNumericValue(value) { + return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)); +} + +function formatGameMode(value) { + if (!value) { + return "No disponible"; + } + const normalized = String(value).replaceAll("_", " ").replaceAll("-", " "); + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function formatDuration(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) { + return "No disponible"; + } + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return hours > 0 ? `${hours} h ${remainingMinutes} min` : `${minutes} min`; +} + +function formatKillFeedCoverage(scope) { + if (scope === "open-admin-log-match-window") { + return "Bajas detectadas en la partida actual."; + } + if (scope === "recent-admin-log-window") { + return "Cobertura parcial desde AdminLog reciente."; + } + return "Todavia no se han detectado bajas en esta partida."; +} + +function normalizeLookupText(value) { + return String(value || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + function setState(node, message, isError = false) { node.textContent = message; node.hidden = false; diff --git a/frontend/partida-actual.html b/frontend/partida-actual.html index 4d73dc2..ec0d0e4 100644 --- a/frontend/partida-actual.html +++ b/frontend/partida-actual.html @@ -10,6 +10,7 @@ Partida actual - HLL Vietnam +
@@ -58,6 +59,23 @@
+
+ +
+ Mapa en vivo + Esperando imagen disponible. +
+
@@ -88,7 +106,7 @@

Combate

Feed de combate

- Los eventos de bajas en vivo apareceran aqui cuando se habiliten. + Leyendo eventos recientes con cobertura segura para esta partida.