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

View File

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

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 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()

View File

@@ -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;
}

View File

@@ -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 `
<article class="historical-stat-card">
<p>${escapeHtml(label)}</p>
<article>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</article>
`;
@@ -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 `
<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) {
node.textContent = message;
node.hidden = false;

View File

@@ -10,6 +10,7 @@
<title>Partida actual - HLL Vietnam</title>
<link rel="stylesheet" href="./assets/css/styles.css" />
<link rel="stylesheet" href="./assets/css/historico.css" />
<link rel="stylesheet" href="./assets/css/historico-scoreboard-detail.css" />
</head>
<body data-backend-base-url="http://127.0.0.1:8000">
<div class="page-shell historical-shell">
@@ -58,6 +59,23 @@
</a>
</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>
</header>
@@ -88,7 +106,7 @@
<p class="eyebrow eyebrow--section">Combate</p>
<h2 id="current-match-feed-title">Feed de combate</h2>
<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>
</div>
</div>