From da3f1643a2b2e98c5dd3d4c22ba66f179c6cdb3c Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 21 May 2026 15:18:03 +0200 Subject: [PATCH] feat: add current match page and live kill feed --- ai/system-metrics.md | 1 + ...d-history-and-current-match-entrypoints.md | 23 +- .../TASK-151-current-match-page-base.md | 27 +- .../TASK-152-current-match-live-kill-feed.md | 31 ++- backend/app/payloads.py | 58 ++++ backend/app/rcon_admin_log_storage.py | 102 +++++++ backend/app/routes.py | 25 ++ backend/tests/test_rcon_admin_log_storage.py | 50 ++++ frontend/assets/css/styles.css | 2 + frontend/assets/js/main.js | 63 ++--- frontend/assets/js/partida-actual.js | 256 ++++++++++++++++++ frontend/partida-actual.html | 115 ++++++++ 12 files changed, 703 insertions(+), 50 deletions(-) rename ai/tasks/{pending => done}/TASK-150-server-card-history-and-current-match-entrypoints.md (83%) rename ai/tasks/{pending => done}/TASK-151-current-match-page-base.md (78%) rename ai/tasks/{pending => done}/TASK-152-current-match-live-kill-feed.md (71%) create mode 100644 frontend/assets/js/partida-actual.js create mode 100644 frontend/partida-actual.html diff --git a/ai/system-metrics.md b/ai/system-metrics.md index 9ea39f4..3bf6f3f 100644 --- a/ai/system-metrics.md +++ b/ai/system-metrics.md @@ -18,3 +18,4 @@ Date | Task | Duration | Result | Notes 2026-05-19T08:39:12 | worker-cycle | 663.71 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-21T15:17:18 | worker-cycle | 688.86 sec | success | codex-runner diff --git a/ai/tasks/pending/TASK-150-server-card-history-and-current-match-entrypoints.md b/ai/tasks/done/TASK-150-server-card-history-and-current-match-entrypoints.md similarity index 83% rename from ai/tasks/pending/TASK-150-server-card-history-and-current-match-entrypoints.md rename to ai/tasks/done/TASK-150-server-card-history-and-current-match-entrypoints.md index 41e9456..f89246b 100644 --- a/ai/tasks/pending/TASK-150-server-card-history-and-current-match-entrypoints.md +++ b/ai/tasks/done/TASK-150-server-card-history-and-current-match-entrypoints.md @@ -1,7 +1,7 @@ --- id: TASK-150 title: Server card history and current match entrypoints -status: pending +status: done type: frontend team: Frontend Senior supporting_teams: [] @@ -164,9 +164,24 @@ historical page can open directly filtered by server. ## Outcome -Document the validation performed, URL trust boundary decisions, historical -filter initialization behavior, and any follow-up task instead of expanding -scope. +- Replaced the home server-card history URL path with a trusted frontend action + catalog for active servers `comunidad-hispana-01` and + `comunidad-hispana-02` only. Payload-provided `community_history_url` + values and the previous server #03 fallback are no longer used for those + card actions. +- Added the three per-server entrypoints from trusted constants: public + scoreboard base URL, internal historical filter URL and internal current + match URL. +- Confirmed `frontend/assets/js/historico.js` already normalizes the supported + `?server=` values into the active selector and falls back to `all-servers` + for unknown or missing values, so no historical page code change was needed. +- Added a minimal `partida-actual.html` placeholder with a trusted server label + map and safe internal links for TASK-151 to upgrade. +- Validation: `node --check frontend/assets/js/main.js`. +- Scope review: `git diff --name-only` and `git status --short` were reviewed + for the task move plus frontend files. Browser click verification was not + completed because the in-app browser JavaScript control tool was not + available after tool discovery in this session. ## Change Budget diff --git a/ai/tasks/pending/TASK-151-current-match-page-base.md b/ai/tasks/done/TASK-151-current-match-page-base.md similarity index 78% rename from ai/tasks/pending/TASK-151-current-match-page-base.md rename to ai/tasks/done/TASK-151-current-match-page-base.md index 0a893aa..cc129fc 100644 --- a/ai/tasks/pending/TASK-151-current-match-page-base.md +++ b/ai/tasks/done/TASK-151-current-match-page-base.md @@ -1,7 +1,7 @@ --- id: TASK-151 title: Current match page base -status: pending +status: done type: frontend team: Frontend Senior supporting_teams: @@ -159,8 +159,29 @@ live server/match state without pretending the match is closed. ## Outcome -Document the live-state source, polling behavior, validation performed, and any -follow-up task instead of expanding scope. +- Upgraded `partida-actual.html` into the first internal live match page and + kept it aligned with the existing historical shell/styles. +- Added frontend polling every 30 seconds with an in-flight guard. The page + rejects unknown `?server=` values before building any external link, and the + public scoreboard button is populated only from the trusted backend + projection. +- Added read-only `GET /api/current-match?server=`. It supports only active + trusted scoreboard origins and projects the existing live server snapshot + fields into the current-match shape. The current live snapshot persistence + exposes status, map, population and capture time; score, game mode and match + start fields remain `null`/unavailable when the snapshot source does not + provide them. +- Kept the combat feed and live player statistics as honest empty placeholders + for the follow-up tasks rather than fabricating closed-match or kill data. +- Validation: `python -m compileall backend/app`; + `node --check frontend/assets/js/partida-actual.js`; + `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`; + narrow inline route guard check for missing/unknown current-match server + values; narrow inline payload projection check using a controlled live + snapshot document. +- Scope review: `git diff --name-only` and `git status --short` were reviewed. + No focused product route test file exists in `backend/tests` for this API + bootstrap layer yet. ## Change Budget diff --git a/ai/tasks/pending/TASK-152-current-match-live-kill-feed.md b/ai/tasks/done/TASK-152-current-match-live-kill-feed.md similarity index 71% rename from ai/tasks/pending/TASK-152-current-match-live-kill-feed.md rename to ai/tasks/done/TASK-152-current-match-live-kill-feed.md index be2adc5..ba5bcff 100644 --- a/ai/tasks/pending/TASK-152-current-match-live-kill-feed.md +++ b/ai/tasks/done/TASK-152-current-match-live-kill-feed.md @@ -1,7 +1,7 @@ --- id: TASK-152 title: Current match live kill feed -status: pending +status: done type: backend team: Backend Senior supporting_teams: @@ -139,8 +139,33 @@ feed based on RCON/AdminLog data. ## Outcome -Document the feed scope and confidence behavior, event normalization choices, -polling validation, and any follow-up task instead of expanding scope. +- Added read-only `GET /api/current-match/kills?server=&limit=` for the active + trusted Comunidad Hispana servers. Unsupported servers fail before any + AdminLog query is built. +- Added a safe AdminLog kill feed read that emits normalized fields only: + generated `event_id`, event time/server time, killer/victim names and teams, + weapon, and computed `is_teamkill`. It does not return raw AdminLog text, + player ids or admin-only payload fields. +- Feed scope prefers `open-admin-log-match-window` with + `confidence: "admin-log-boundary"` when the latest AdminLog boundary for the + server is an unmatched `match_start`. Otherwise it returns + `recent-admin-log-window` with `confidence: "partial"` as the explicit + fallback. +- Extended `partida-actual.js` to poll the feed with the existing 30-second + in-flight-protected current-match refresh cycle, dedupe rows by `event_id`, + keep newest events ordered by server/event time, render a TK badge for + teamkills, and show empty/error states without fake rows. +- Added a focused AdminLog storage test for open-window filtering and teamkill + normalization. The environment does not have `pytest` installed (`pytest` + and `python -m pytest` both failed), so the same storage normalization path + was verified with a narrow inline Python scenario. +- Validation: `python -m compileall backend/app`; + `node --check frontend/assets/js/partida-actual.js`; + `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`; + narrow inline route guard check for missing, unknown and invalid-limit feed + requests; inline storage normalization check excluding pre-window kills and + confirming no raw message field leaks. +- Scope review: `git diff --name-only` and `git status --short` were reviewed. ## Change Budget diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 2969d42..68bd7b9 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_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 @@ -237,6 +238,63 @@ def build_server_detail_history_payload( } +def build_current_match_payload(*, server_slug: str) -> dict[str, object]: + """Return the live page projection for one trusted active server.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + if origin is None: + raise ValueError("Unsupported current match server.") + + server_payload = build_servers_payload() + server_data = server_payload["data"] + item = next( + ( + candidate + for candidate in server_data.get("items", []) + if candidate.get("external_server_id") == origin.slug + ), + None, + ) + return { + "status": "ok", + "data": { + "found": item is not None, + "server_slug": origin.slug, + "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, + "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, + "players": item.get("players") if item else None, + "max_players": item.get("max_players") 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, + }, + } + + +def build_current_match_kill_feed_payload( + *, + server_slug: str, + limit: int = 30, +) -> dict[str, object]: + """Return normalized AdminLog kill rows for one trusted current-match page.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + if origin is None: + raise ValueError("Unsupported current match server.") + feed = list_current_match_kill_feed(server_key=origin.slug, limit=limit) + return { + "status": "ok", + "data": { + "server_slug": origin.slug, + "server_name": origin.display_name, + **feed, + }, + } + + 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 8b00ea6..6672f54 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -6,6 +6,7 @@ import json import re import sqlite3 from collections.abc import Mapping +from contextlib import closing from pathlib import Path from .config import get_storage_path, use_postgres_rcon_storage @@ -328,6 +329,80 @@ def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dic return [dict(row) for row in rows] +def list_current_match_kill_feed( + *, + server_key: str, + limit: int = 30, + db_path: Path | 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) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + connection_scope = connect_postgres_compat() + else: + connection_scope = closing(sqlite3.connect(resolved_path)) + + with connection_scope as connection: + if isinstance(connection, sqlite3.Connection): + connection.row_factory = sqlite3.Row + boundary = connection.execute( + """ + SELECT event_type, server_time + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type IN ('match_start', 'match_end') + AND server_time IS NOT NULL + ORDER BY server_time DESC, id DESC + LIMIT 1 + """, + (server_key, server_key), + ).fetchone() + open_start_time = ( + boundary["server_time"] + if boundary is not None and boundary["event_type"] == "match_start" + else None + ) + if open_start_time is None: + rows = connection.execute( + """ + SELECT id, target_key, external_server_id, event_timestamp, server_time, + parsed_payload_json + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type = 'kill' + ORDER BY server_time DESC, id DESC + LIMIT ? + """, + (server_key, server_key, limit), + ).fetchall() + scope = "recent-admin-log-window" + confidence = "partial" + else: + rows = connection.execute( + """ + SELECT id, target_key, external_server_id, event_timestamp, server_time, + parsed_payload_json + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type = 'kill' + AND server_time >= ? + ORDER BY server_time DESC, id DESC + LIMIT ? + """, + (server_key, server_key, open_start_time, limit), + ).fetchall() + scope = "open-admin-log-match-window" + confidence = "admin-log-boundary" + + return { + "scope": scope, + "confidence": confidence, + "items": [_serialize_kill_feed_row(row) for row in rows], + } + + def get_latest_rcon_player_profile_summaries( *, target_key: str, @@ -422,6 +497,33 @@ def _json_mapping(raw_value: object) -> dict[str, object]: return parsed if isinstance(parsed, dict) else {} +def _serialize_kill_feed_row(row: Mapping[str, object]) -> dict[str, object]: + payload = _json_mapping(row["parsed_payload_json"]) + target_key = str(row["external_server_id"] or row["target_key"] or "unknown") + killer_team = _safe_event_field(payload.get("killer_team")) + victim_team = _safe_event_field(payload.get("victim_team")) + return { + "event_id": f"rcon-admin-log:{target_key}:{row['id']}", + "event_timestamp": row["event_timestamp"], + "server_time": row["server_time"], + "killer_name": _safe_event_field(payload.get("killer_name")), + "killer_team": killer_team, + "victim_name": _safe_event_field(payload.get("victim_name")), + "victim_team": victim_team, + "weapon": _safe_event_field(payload.get("weapon")), + "is_teamkill": bool( + killer_team + and killer_team != "None" + and killer_team == victim_team + ), + } + + +def _safe_event_field(value: object) -> str | None: + normalized = str(value or "").strip() + return normalized or None + + def _persist_rcon_admin_log_entries_postgres( *, target: Mapping[str, object], diff --git a/backend/app/routes.py b/backend/app/routes.py index 607ffac..c1dea5d 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -7,6 +7,8 @@ from urllib.parse import parse_qs, urlparse from .payloads import ( build_community_payload, + build_current_match_kill_feed_payload, + build_current_match_payload, build_discord_payload, build_elo_mmr_leaderboard_payload, build_elo_mmr_player_payload, @@ -37,6 +39,7 @@ from .payloads import ( build_weekly_leaderboard_payload, build_weekly_top_kills_payload, ) +from .scoreboard_origins import get_trusted_public_scoreboard_origin GET_ROUTES = { @@ -60,6 +63,28 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") return HTTPStatus.OK, build_server_history_payload(limit=limit) + if parsed.path == "/api/current-match": + 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_payload(server_slug=server_slug) + + if parsed.path == "/api/current-match/kills": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + 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_kill_feed_payload( + server_slug=server_slug, + limit=limit, + ) + if parsed.path == "/api/historical/weekly-top-kills": limit = _parse_limit(parsed.query) if limit is None: diff --git a/backend/tests/test_rcon_admin_log_storage.py b/backend/tests/test_rcon_admin_log_storage.py index b59ff91..8848a81 100644 --- a/backend/tests/test_rcon_admin_log_storage.py +++ b/backend/tests/test_rcon_admin_log_storage.py @@ -4,6 +4,7 @@ import sqlite3 from app.rcon_admin_log_storage import ( initialize_rcon_admin_log_storage, + list_current_match_kill_feed, list_rcon_admin_log_event_counts, persist_rcon_admin_log_entries, ) @@ -259,3 +260,52 @@ def test_list_rcon_admin_log_event_counts_groups_by_target_and_event_type(tmp_pa }, } gc.collect() + + +def test_current_match_kill_feed_prefers_open_match_window_and_normalizes_rows(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-19T09:59:00Z", + "message": ( + "[0:59 min (90)] KILL: Old Killer(Allies/steam-old) -> " + "Old Victim(Axis/steam-victim-old) with M1 GARAND" + ), + }, + { + "timestamp": "2026-05-19T10:00:00Z", + "message": "[1:00 min (100)] MATCH START Mortain Warfare", + }, + { + "timestamp": "2026-05-19T10:01:00Z", + "message": ( + "[2:00 min (120)] KILL: Alpha(Allies/steam-alpha) -> " + "Bravo(Allies/steam-bravo) with GRENADE" + ), + }, + ], + db_path=db_path, + ) + + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + ) + + assert feed["scope"] == "open-admin-log-match-window" + assert feed["confidence"] == "admin-log-boundary" + assert len(feed["items"]) == 1 + assert feed["items"][0] == { + "event_id": "rcon-admin-log:test-rcon-target:3", + "event_timestamp": "2026-05-19T10:01:00Z", + "server_time": 120, + "killer_name": "Alpha", + "killer_team": "Allies", + "victim_name": "Bravo", + "victim_team": "Allies", + "weapon": "GRENADE", + "is_teamkill": True, + } + gc.collect() diff --git a/frontend/assets/css/styles.css b/frontend/assets/css/styles.css index 6833dc1..134ba11 100644 --- a/frontend/assets/css/styles.css +++ b/frontend/assets/css/styles.css @@ -671,6 +671,8 @@ h2 { margin: 0; width: 100%; display: flex; + flex-direction: column; + gap: 8px; justify-content: flex-end; } diff --git a/frontend/assets/js/main.js b/frontend/assets/js/main.js index 19a3de0..9237d14 100644 --- a/frontend/assets/js/main.js +++ b/frontend/assets/js/main.js @@ -1,9 +1,16 @@ // Progressive enhancement for local frontend-backend checks. const DEFAULT_SERVER_POLL_INTERVAL_MS = 300 * 1000; -const SERVER_HISTORY_URLS_FALLBACK = Object.freeze({ - "comunidad-hispana-01": "https://scoreboard.comunidadhll.es/games", - "comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443/games", - "comunidad-hispana-03": "https://scoreboard.comunidadhll.es:3443/games", +const TRUSTED_SERVER_ACTIONS = Object.freeze({ + "comunidad-hispana-01": Object.freeze({ + publicScoreboardUrl: "https://scoreboard.comunidadhll.es", + historicalUrl: "./historico.html?server=comunidad-hispana-01", + currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-01", + }), + "comunidad-hispana-02": Object.freeze({ + publicScoreboardUrl: "https://scoreboard.comunidadhll.es:5443", + historicalUrl: "./historico.html?server=comunidad-hispana-02", + currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-02", + }), }); const COMMUNITY_CLANS = Object.freeze([ { @@ -328,22 +335,8 @@ function normalizeServerRegion(value) { } function renderServerAction(server) { - const historyState = getServerHistoryState(server); - if (!historyState.available) { - return ` -
- - Historico no disponible - -
- `; - } - - const historyUrl = historyState.url; - if (!historyUrl) { + const actions = getTrustedServerActions(server); + if (!actions) { return ""; } @@ -351,11 +344,17 @@ function renderServerAction(server) {
- Historico + Scoreboard publico + + + Nuestro historico + + + Partida actual
`; @@ -458,28 +457,12 @@ function renderQuickFacts(items) { `; } -function getServerHistoryUrl(server) { - if (typeof server?.community_history_url === "string" && server.community_history_url.trim()) { - return server.community_history_url.trim(); - } - +function getTrustedServerActions(server) { const externalServerId = typeof server?.external_server_id === "string" ? server.external_server_id.trim() : ""; - if (!externalServerId) { - return ""; - } - - return SERVER_HISTORY_URLS_FALLBACK[externalServerId] || ""; -} - -function getServerHistoryState(server) { - const historyUrl = getServerHistoryUrl(server); - return { - available: Boolean(historyUrl), - url: historyUrl, - }; + return TRUSTED_SERVER_ACTIONS[externalServerId] || null; } function selectPrimaryServerItems(items) { diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js new file mode 100644 index 0000000..3b2f8c7 --- /dev/null +++ b/frontend/assets/js/partida-actual.js @@ -0,0 +1,256 @@ +const CURRENT_MATCH_POLL_INTERVAL_MS = 30 * 1000; +const CURRENT_MATCH_SERVERS = Object.freeze({ + "comunidad-hispana-01": "Comunidad Hispana #01", + "comunidad-hispana-02": "Comunidad Hispana #02", +}); + +document.addEventListener("DOMContentLoaded", () => { + const params = new URLSearchParams(window.location.search); + const serverSlug = params.get("server") || ""; + const nodes = { + title: document.getElementById("current-match-title"), + summary: document.getElementById("current-match-summary"), + history: document.getElementById("current-match-history"), + scoreboard: document.getElementById("current-match-scoreboard"), + note: document.getElementById("current-match-note"), + state: document.getElementById("current-match-state"), + grid: document.getElementById("current-match-grid"), + feedTitle: document.getElementById("current-match-feed-title"), + }; + const backendBaseUrl = + document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; + + if (!CURRENT_MATCH_SERVERS[serverSlug]) { + renderUnsupportedServer(nodes); + return; + } + + nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`; + const killFeedState = initializeKillFeed(nodes); + let refreshInFlight = false; + const refresh = async () => { + if (refreshInFlight) { + return; + } + refreshInFlight = true; + try { + await Promise.allSettled([ + loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }), + loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }), + ]); + } finally { + refreshInFlight = false; + } + }; + + void refresh(); + window.setInterval(() => { + void refresh(); + }, CURRENT_MATCH_POLL_INTERVAL_MS); +}); + +async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) { + try { + const payload = await fetchJson( + `${backendBaseUrl}/api/current-match?server=${encodeURIComponent(serverSlug)}`, + ); + renderCurrentMatch(payload?.data || {}, nodes); + } catch (error) { + nodes.note.textContent = "Se conserva el ultimo estado visible si estaba disponible."; + setState(nodes.state, "No se pudo actualizar la partida actual.", true); + } +} + +async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) { + try { + const payload = await fetchJson( + `${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=30`, + ); + renderKillFeed(payload?.data || {}, nodes, killFeedState); + } catch (error) { + setState(nodes.feedState, "No se pudo actualizar el feed de combate.", true); + } +} + +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; + nodes.note.textContent = data.found + ? "Snapshot en vivo recibido. 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.state.hidden = true; + nodes.grid.hidden = false; +} + +function renderUnsupportedServer(nodes) { + nodes.title.textContent = "Servidor no soportado"; + nodes.summary.textContent = + "Abre esta vista desde una tarjeta activa de Comunidad Hispana."; + nodes.note.textContent = ""; + nodes.scoreboard.hidden = true; + nodes.grid.hidden = true; + setState(nodes.state, "No se puede consultar la partida solicitada.", true); +} + +function initializeKillFeed(nodes) { + const feedShell = nodes.feedTitle?.closest(".panel__shell"); + if (feedShell) { + feedShell.insertAdjacentHTML( + "beforeend", + ` +

+ Cargando feed de combate... +

+
+ `, + ); + } + nodes.feedState = document.getElementById("current-match-feed-state"); + nodes.feedList = document.getElementById("current-match-feed-list"); + return { + byId: new Map(), + }; +} + +function renderKillFeed(data, nodes, state) { + const incoming = Array.isArray(data.items) ? data.items : []; + incoming.forEach((event) => { + if (event?.event_id) { + state.byId.set(event.event_id, event); + } + }); + const events = [...state.byId.values()].sort(compareKillFeedEvents).slice(0, 30); + if (events.length === 0) { + nodes.feedList.innerHTML = ""; + setState(nodes.feedState, "Todavia no se han detectado bajas en esta partida."); + 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.classList.remove("historical-state--error"); +} + +function compareKillFeedEvents(left, right) { + const rightTime = Number(right.server_time); + const leftTime = Number(left.server_time); + if (Number.isFinite(rightTime) && Number.isFinite(leftTime) && rightTime !== leftTime) { + return rightTime - leftTime; + } + return String(right.event_timestamp || "").localeCompare(String(left.event_timestamp || "")); +} + +function renderKillFeedRow(event) { + const teamkillBadge = event.is_teamkill + ? '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")} +

+
+ `; +} + +function formatEventTime(event) { + const timestamp = formatTimestamp(event.event_timestamp); + if (timestamp !== "No disponible") { + return timestamp; + } + return Number.isFinite(Number(event.server_time)) + ? `Tiempo servidor ${Number(event.server_time)}` + : "Tiempo no disponible"; +} + +function renderStat(label, value) { + return ` +
+

${escapeHtml(label)}

+ ${escapeHtml(value)} +
+ `; +} + +function formatStatus(value) { + if (value === "online") { + return "Online"; + } + if (value === "offline") { + return "Offline"; + } + return "No disponible"; +} + +function formatPlayers(players, maxPlayers) { + if (!Number.isFinite(Number(players)) || !Number.isFinite(Number(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"; + } + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return "No disponible"; + } + return new Intl.DateTimeFormat("es-ES", { + dateStyle: "short", + timeStyle: "short", + }).format(timestamp); +} + +function setState(node, message, isError = false) { + node.textContent = message; + node.hidden = false; + node.classList.toggle("historical-state--error", isError); +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed with ${response.status}`); + } + return response.json(); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/frontend/partida-actual.html b/frontend/partida-actual.html new file mode 100644 index 0000000..4d73dc2 --- /dev/null +++ b/frontend/partida-actual.html @@ -0,0 +1,115 @@ + + + + + + + Partida actual - HLL Vietnam + + + + +
+
+
+
+
+ + VOLVER INICIO + +

Partida en vivo

+
+
+
+ Logo oficial de la comunidad HLL Vietnam +
+
+
+

+ Partida actual +

+

+ Cargando estado del servidor. +

+
+ +
+
+
+
+ +
+
+
+
+
+

Estado en vivo

+

Marcador en curso

+

+ Leyendo el ultimo snapshot disponible. +

+
+
+

+ Cargando partida actual... +

+ +
+
+ +
+
+
+
+

Combate

+

Feed de combate

+

+ Los eventos de bajas en vivo apareceran aqui cuando se habiliten. +

+
+
+
+
+ +
+
+
+
+

Jugadores

+

Estadisticas en vivo

+

+ Las estadisticas en vivo apareceran cuando haya datos suficientes. +

+
+
+
+
+
+
+ + +