fix: project current match live data safely

This commit is contained in:
devRaGonSa
2026-05-21 16:39:18 +02:00
parent da3f1643a2
commit 45801bb955
8 changed files with 715 additions and 29 deletions

View File

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

View File

@@ -49,6 +49,7 @@ from .historical_storage import (
) )
from .rcon_historical_read_model import get_rcon_historical_match_detail from .rcon_historical_read_model import get_rcon_historical_match_detail
from .normalizers import normalize_map_name from .normalizers import normalize_map_name
from .rcon_client import load_rcon_targets, query_live_server_sample
from .rcon_admin_log_storage import list_current_match_kill_feed from .rcon_admin_log_storage import list_current_match_kill_feed
from .scoreboard_origins import get_trusted_public_scoreboard_origin from .scoreboard_origins import get_trusted_public_scoreboard_origin
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
@@ -244,6 +245,61 @@ def build_current_match_payload(*, server_slug: str) -> dict[str, object]:
if origin is None: if origin is None:
raise ValueError("Unsupported current match server.") 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_payload = build_servers_payload()
server_data = server_payload["data"] server_data = server_payload["data"]
item = next( 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, "server_name": item.get("server_name") if item else origin.display_name,
"status": item.get("status") if item else "unavailable", "status": item.get("status") if item else "unavailable",
"map": item.get("current_map") if item else None, "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, "game_mode": item.get("game_mode") if item else None,
"started_at": item.get("started_at") if item else None, "started_at": item.get("started_at") if item else None,
"allied_score": item.get("allied_score") if item else None, "allied_score": item.get("allied_score") if item else None,
"axis_score": item.get("axis_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, "players": item.get("players") if item else None,
"max_players": item.get("max_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, "captured_at": item.get("captured_at") if item else None,
"updated_at": server_data.get("last_snapshot_at"), "updated_at": server_data.get("last_snapshot_at"),
"public_scoreboard_url": origin.base_url, "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]: def build_error_payload(message: str) -> dict[str, str]:
"""Return the shared error payload shape used by the backend bootstrap.""" """Return the shared error payload shape used by the backend bootstrap."""
return { return {

View File

@@ -7,6 +7,7 @@ import re
import sqlite3 import sqlite3
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import closing from contextlib import closing
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from .config import get_storage_path, use_postgres_rcon_storage 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 .rcon_historical_storage import initialize_rcon_historical_storage
from .sqlite_utils import connect_sqlite_writer 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: def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
"""Create SQLite structures for parsed RCON AdminLog events.""" """Create SQLite structures for parsed RCON AdminLog events."""
@@ -334,6 +337,7 @@ def list_current_match_kill_feed(
server_key: str, server_key: str,
limit: int = 30, limit: int = 30,
db_path: Path | None = None, db_path: Path | None = None,
now: datetime | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
"""Return safe recent kill rows for one AdminLog server window.""" """Return safe recent kill rows for one AdminLog server window."""
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) 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" scope = "open-admin-log-match-window"
confidence = "admin-log-boundary" 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 { return {
"scope": scope, "scope": scope,
"confidence": confidence, "confidence": confidence,
"stale_events_filtered": stale_events_filtered,
"items": [_serialize_kill_feed_row(row) for row in rows], "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 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( def _persist_rcon_admin_log_entries_postgres(
*, *,
target: Mapping[str, object], target: Mapping[str, object],

View File

@@ -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"]

View File

@@ -1,6 +1,7 @@
import gc import gc
import json import json
import sqlite3 import sqlite3
from datetime import datetime, timezone
from app.rcon_admin_log_storage import ( from app.rcon_admin_log_storage import (
initialize_rcon_admin_log_storage, 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, "is_teamkill": True,
} }
gc.collect() 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()

View File

@@ -88,6 +88,47 @@
object-fit: cover; 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 { .historical-content {
gap: 22px; gap: 22px;
} }

View File

@@ -3,6 +3,10 @@ const CURRENT_MATCH_SERVERS = Object.freeze({
"comunidad-hispana-01": "Comunidad Hispana #01", "comunidad-hispana-01": "Comunidad Hispana #01",
"comunidad-hispana-02": "Comunidad Hispana #02", "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", () => { document.addEventListener("DOMContentLoaded", () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -16,6 +20,9 @@ document.addEventListener("DOMContentLoaded", () => {
state: document.getElementById("current-match-state"), state: document.getElementById("current-match-state"),
grid: document.getElementById("current-match-grid"), grid: document.getElementById("current-match-grid"),
feedTitle: document.getElementById("current-match-feed-title"), feedTitle: document.getElementById("current-match-feed-title"),
mapHero: document.getElementById("current-match-map-hero"),
mapImage: document.getElementById("current-match-map-image"),
mapPlaceholder: document.getElementById("current-match-map-placeholder"),
}; };
const backendBaseUrl = const backendBaseUrl =
document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; 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) { function renderCurrentMatch(data, nodes) {
const serverName = data.server_name || data.server_slug || "Servidor no disponible"; const serverName = data.server_name || data.server_slug || "Servidor no disponible";
const mapName = data.map || "Mapa no disponible"; const mapName = data.map_pretty_name || data.map || "Mapa no disponible";
nodes.title.textContent = serverName; const scoreboardUrl = resolveTrustedScoreboardUrl(data);
nodes.summary.textContent = mapName; nodes.title.textContent = mapName;
nodes.summary.textContent = serverName;
nodes.note.textContent = data.found 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."; : "Todavia no hay snapshot live disponible para este servidor.";
nodes.scoreboard.href = data.public_scoreboard_url; nodes.scoreboard.href = scoreboardUrl || "./index.html";
nodes.scoreboard.hidden = !data.public_scoreboard_url; nodes.scoreboard.hidden = !scoreboardUrl;
nodes.grid.innerHTML = [ renderMapHero(data, mapName, nodes);
renderStat("Estado", formatStatus(data.status)), nodes.grid.innerHTML = renderLiveScoreboard(data, { mapName, serverName });
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.state.hidden = true;
nodes.grid.hidden = false; nodes.grid.hidden = false;
} }
@@ -103,6 +103,7 @@ function renderUnsupportedServer(nodes) {
nodes.note.textContent = ""; nodes.note.textContent = "";
nodes.scoreboard.hidden = true; nodes.scoreboard.hidden = true;
nodes.grid.hidden = true; nodes.grid.hidden = true;
renderMapHero({}, "Mapa no disponible", nodes);
setState(nodes.state, "No se puede consultar la partida solicitada.", true); setState(nodes.state, "No se puede consultar la partida solicitada.", true);
} }
@@ -128,6 +129,9 @@ function initializeKillFeed(nodes) {
function renderKillFeed(data, nodes, state) { function renderKillFeed(data, nodes, state) {
const incoming = Array.isArray(data.items) ? data.items : []; const incoming = Array.isArray(data.items) ? data.items : [];
if (data.scope === "no-current-match-events") {
state.byId.clear();
}
incoming.forEach((event) => { incoming.forEach((event) => {
if (event?.event_id) { if (event?.event_id) {
state.byId.set(event.event_id, event); state.byId.set(event.event_id, event);
@@ -140,10 +144,7 @@ function renderKillFeed(data, nodes, state) {
return; return;
} }
nodes.feedList.innerHTML = events.map(renderKillFeedRow).join(""); nodes.feedList.innerHTML = events.map(renderKillFeedRow).join("");
nodes.feedState.textContent = nodes.feedState.textContent = formatKillFeedCoverage(data.scope);
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"); nodes.feedState.classList.remove("historical-state--error");
} }
@@ -188,10 +189,10 @@ function formatEventTime(event) {
: "Tiempo no disponible"; : "Tiempo no disponible";
} }
function renderStat(label, value) { function renderCompactMeta(label, value) {
return ` return `
<article class="historical-stat-card"> <article>
<p>${escapeHtml(label)}</p> <span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong> <strong>${escapeHtml(value)}</strong>
</article> </article>
`; `;
@@ -208,16 +209,12 @@ function formatStatus(value) {
} }
function formatPlayers(players, maxPlayers) { function formatPlayers(players, maxPlayers) {
if (!Number.isFinite(Number(players)) || !Number.isFinite(Number(maxPlayers))) { if (!isNumericValue(players) || !isNumericValue(maxPlayers)) {
return "No disponible"; return "No disponible";
} }
return `${Number(players)} / ${Number(maxPlayers)}`; return `${Number(players)} / ${Number(maxPlayers)}`;
} }
function formatScore(value) {
return Number.isFinite(Number(value)) ? String(Number(value)) : "No disponible";
}
function formatTimestamp(value) { function formatTimestamp(value) {
if (!value) { if (!value) {
return "No disponible"; return "No disponible";
@@ -232,6 +229,183 @@ function formatTimestamp(value) {
}).format(timestamp); }).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 `
<section class="historical-scoreboard-layout" aria-label="Marcador en vivo">
<div class="historical-scoreboard-layout__main">
${renderLiveSide("historical-scoreboard-side--allied", "Aliados", "./assets/img/factions/us.webp")}
<div class="historical-scoreboard-center">
<span class="historical-scoreboard-center__timer">${escapeHtml(formatStatus(data.status))}</span>
<strong class="historical-scoreboard-center__score${scoreClass}">${escapeHtml(scoreMarkup)}</strong>
<span class="historical-scoreboard-center__map">${escapeHtml(mapName)}</span>
<span class="historical-scoreboard-center__mode">${escapeHtml(formatGameMode(data.game_mode))}</span>
</div>
${renderLiveSide("historical-scoreboard-side--axis", "Eje", "./assets/img/factions/germany.webp")}
</div>
<div class="historical-scoreboard-layout__meta">
${metadata.map(([label, value]) => renderCompactMeta(label, value)).join("")}
</div>
</section>
`;
}
function renderLiveSide(sideClass, label, emblem) {
return `
<div class="historical-scoreboard-side ${sideClass}">
<img
class="historical-scoreboard-side__emblem"
src="${escapeHtml(emblem)}"
alt="${escapeHtml(label)}"
width="128"
height="128"
loading="lazy"
decoding="async"
onerror="this.hidden = true; this.closest('.historical-scoreboard-side').classList.add('is-emblem-missing');"
/>
<div class="historical-scoreboard-side__text">
<strong>${escapeHtml(label)}</strong>
</div>
</div>
`;
}
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) { function setState(node, message, isError = false) {
node.textContent = message; node.textContent = message;
node.hidden = false; node.hidden = false;

View File

@@ -10,6 +10,7 @@
<title>Partida actual - HLL Vietnam</title> <title>Partida actual - HLL Vietnam</title>
<link rel="stylesheet" href="./assets/css/styles.css" /> <link rel="stylesheet" href="./assets/css/styles.css" />
<link rel="stylesheet" href="./assets/css/historico.css" /> <link rel="stylesheet" href="./assets/css/historico.css" />
<link rel="stylesheet" href="./assets/css/historico-scoreboard-detail.css" />
</head> </head>
<body data-backend-base-url="http://127.0.0.1:8000"> <body data-backend-base-url="http://127.0.0.1:8000">
<div class="page-shell historical-shell"> <div class="page-shell historical-shell">
@@ -58,6 +59,23 @@
</a> </a>
</div> </div>
</div> </div>
<figure class="historical-map-hero current-match-map-hero" id="current-match-map-hero">
<img
class="historical-map-hero__image"
id="current-match-map-image"
src=""
alt=""
width="960"
height="540"
loading="eager"
decoding="async"
hidden
/>
<div class="current-match-map-placeholder" id="current-match-map-placeholder">
<strong>Mapa en vivo</strong>
<span>Esperando imagen disponible.</span>
</div>
</figure>
</div> </div>
</div> </div>
</header> </header>
@@ -88,7 +106,7 @@
<p class="eyebrow eyebrow--section">Combate</p> <p class="eyebrow eyebrow--section">Combate</p>
<h2 id="current-match-feed-title">Feed de combate</h2> <h2 id="current-match-feed-title">Feed de combate</h2>
<p class="historical-panel__note"> <p class="historical-panel__note">
Los eventos de bajas en vivo apareceran aqui cuando se habiliten. Leyendo eventos recientes con cobertura segura para esta partida.
</p> </p>
</div> </div>
</div> </div>