${escapeHtml(eventTime)}
+ ${escapeHtml(event.killer_name || "Jugador no disponible")} ++ ${escapeHtml(event.weapon || "Arma no disponible")} + -> ${escapeHtml(event.victim_name || "Objetivo no disponible")} +
+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 ` -
+ 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")} ++ ${escapeHtml(event.weapon || "Arma no disponible")} + -> ${escapeHtml(event.victim_name || "Objetivo no disponible")} +
+${escapeHtml(label)}
+ ${escapeHtml(value)} +Partida en vivo
+
+ + Cargando estado del servidor. +
+Estado en vivo
++ Leyendo el ultimo snapshot disponible. +
++ Cargando partida actual... +
+ +Combate
++ Los eventos de bajas en vivo apareceran aqui cuando se habiliten. +
+Jugadores
++ Las estadisticas en vivo apareceran cuando haya datos suficientes. +
+