feat: add current match page and live kill feed

This commit is contained in:
devRaGonSa
2026-05-21 15:18:03 +02:00
parent 4544cb3c0a
commit da3f1643a2
12 changed files with 703 additions and 50 deletions

View File

@@ -18,3 +18,4 @@ Date | Task | Duration | Result | Notes
2026-05-19T08:39:12 | worker-cycle | 663.71 sec | success | codex-runner 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-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-21T14:43:44 | worker-cycle | 5580.9 sec | success | codex-runner
2026-05-21T15:17:18 | worker-cycle | 688.86 sec | success | codex-runner

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-150 id: TASK-150
title: Server card history and current match entrypoints title: Server card history and current match entrypoints
status: pending status: done
type: frontend type: frontend
team: Frontend Senior team: Frontend Senior
supporting_teams: [] supporting_teams: []
@@ -164,9 +164,24 @@ historical page can open directly filtered by server.
## Outcome ## Outcome
Document the validation performed, URL trust boundary decisions, historical - Replaced the home server-card history URL path with a trusted frontend action
filter initialization behavior, and any follow-up task instead of expanding catalog for active servers `comunidad-hispana-01` and
scope. `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 ## Change Budget

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-151 id: TASK-151
title: Current match page base title: Current match page base
status: pending status: done
type: frontend type: frontend
team: Frontend Senior team: Frontend Senior
supporting_teams: supporting_teams:
@@ -159,8 +159,29 @@ live server/match state without pretending the match is closed.
## Outcome ## Outcome
Document the live-state source, polling behavior, validation performed, and any - Upgraded `partida-actual.html` into the first internal live match page and
follow-up task instead of expanding scope. 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 ## Change Budget

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-152 id: TASK-152
title: Current match live kill feed title: Current match live kill feed
status: pending status: done
type: backend type: backend
team: Backend Senior team: Backend Senior
supporting_teams: supporting_teams:
@@ -139,8 +139,33 @@ feed based on RCON/AdminLog data.
## Outcome ## Outcome
Document the feed scope and confidence behavior, event normalization choices, - Added read-only `GET /api/current-match/kills?server=&limit=` for the active
polling validation, and any follow-up task instead of expanding scope. 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 ## Change Budget

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_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
@@ -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]: 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

@@ -6,6 +6,7 @@ import json
import re import re
import sqlite3 import sqlite3
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import closing
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
@@ -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] 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( def get_latest_rcon_player_profile_summaries(
*, *,
target_key: str, target_key: str,
@@ -422,6 +497,33 @@ def _json_mapping(raw_value: object) -> dict[str, object]:
return parsed if isinstance(parsed, dict) else {} 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( def _persist_rcon_admin_log_entries_postgres(
*, *,
target: Mapping[str, object], target: Mapping[str, object],

View File

@@ -7,6 +7,8 @@ from urllib.parse import parse_qs, urlparse
from .payloads import ( from .payloads import (
build_community_payload, build_community_payload,
build_current_match_kill_feed_payload,
build_current_match_payload,
build_discord_payload, build_discord_payload,
build_elo_mmr_leaderboard_payload, build_elo_mmr_leaderboard_payload,
build_elo_mmr_player_payload, build_elo_mmr_player_payload,
@@ -37,6 +39,7 @@ from .payloads import (
build_weekly_leaderboard_payload, build_weekly_leaderboard_payload,
build_weekly_top_kills_payload, build_weekly_top_kills_payload,
) )
from .scoreboard_origins import get_trusted_public_scoreboard_origin
GET_ROUTES = { 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.BAD_REQUEST, build_error_payload("Invalid limit parameter")
return HTTPStatus.OK, build_server_history_payload(limit=limit) 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": if parsed.path == "/api/historical/weekly-top-kills":
limit = _parse_limit(parsed.query) limit = _parse_limit(parsed.query)
if limit is None: if limit is None:

View File

@@ -4,6 +4,7 @@ import sqlite3
from app.rcon_admin_log_storage import ( from app.rcon_admin_log_storage import (
initialize_rcon_admin_log_storage, initialize_rcon_admin_log_storage,
list_current_match_kill_feed,
list_rcon_admin_log_event_counts, list_rcon_admin_log_event_counts,
persist_rcon_admin_log_entries, 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() 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()

View File

@@ -671,6 +671,8 @@ h2 {
margin: 0; margin: 0;
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-end; justify-content: flex-end;
} }

View File

@@ -1,9 +1,16 @@
// Progressive enhancement for local frontend-backend checks. // Progressive enhancement for local frontend-backend checks.
const DEFAULT_SERVER_POLL_INTERVAL_MS = 300 * 1000; const DEFAULT_SERVER_POLL_INTERVAL_MS = 300 * 1000;
const SERVER_HISTORY_URLS_FALLBACK = Object.freeze({ const TRUSTED_SERVER_ACTIONS = Object.freeze({
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es/games", "comunidad-hispana-01": Object.freeze({
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443/games", publicScoreboardUrl: "https://scoreboard.comunidadhll.es",
"comunidad-hispana-03": "https://scoreboard.comunidadhll.es:3443/games", 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([ const COMMUNITY_CLANS = Object.freeze([
{ {
@@ -328,22 +335,8 @@ function normalizeServerRegion(value) {
} }
function renderServerAction(server) { function renderServerAction(server) {
const historyState = getServerHistoryState(server); const actions = getTrustedServerActions(server);
if (!historyState.available) { if (!actions) {
return `
<div class="server-card__actions">
<span
class="server-action-link server-action-link--disabled"
aria-disabled="true"
>
Historico no disponible
</span>
</div>
`;
}
const historyUrl = historyState.url;
if (!historyUrl) {
return ""; return "";
} }
@@ -351,11 +344,17 @@ function renderServerAction(server) {
<div class="server-card__actions"> <div class="server-card__actions">
<a <a
class="server-action-link" class="server-action-link"
href="${escapeHtml(historyUrl)}" href="${escapeHtml(actions.publicScoreboardUrl)}"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
Historico Scoreboard publico
</a>
<a class="server-action-link" href="${escapeHtml(actions.historicalUrl)}">
Nuestro historico
</a>
<a class="server-action-link" href="${escapeHtml(actions.currentMatchUrl)}">
Partida actual
</a> </a>
</div> </div>
`; `;
@@ -458,28 +457,12 @@ function renderQuickFacts(items) {
`; `;
} }
function getServerHistoryUrl(server) { function getTrustedServerActions(server) {
if (typeof server?.community_history_url === "string" && server.community_history_url.trim()) {
return server.community_history_url.trim();
}
const externalServerId = const externalServerId =
typeof server?.external_server_id === "string" typeof server?.external_server_id === "string"
? server.external_server_id.trim() ? server.external_server_id.trim()
: ""; : "";
if (!externalServerId) { return TRUSTED_SERVER_ACTIONS[externalServerId] || null;
return "";
}
return SERVER_HISTORY_URLS_FALLBACK[externalServerId] || "";
}
function getServerHistoryState(server) {
const historyUrl = getServerHistoryUrl(server);
return {
available: Boolean(historyUrl),
url: historyUrl,
};
} }
function selectPrimaryServerItems(items) { function selectPrimaryServerItems(items) {

View File

@@ -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",
`
<p class="historical-state" id="current-match-feed-state" aria-live="polite">
Cargando feed de combate...
</p>
<div class="historical-match-list" id="current-match-feed-list"></div>
`,
);
}
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
? '<span class="status-chip status-chip--fallback">TK</span>'
: "";
const eventTime = formatEventTime(event);
return `
<article class="historical-match-card">
<div class="historical-match-card__top">
<div class="historical-match-card__title">
<p>${escapeHtml(eventTime)}</p>
<strong>${escapeHtml(event.killer_name || "Jugador no disponible")}</strong>
</div>
${teamkillBadge}
</div>
<p>
${escapeHtml(event.weapon || "Arma no disponible")}
-> ${escapeHtml(event.victim_name || "Objetivo no disponible")}
</p>
</article>
`;
}
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 `
<article class="historical-stat-card">
<p>${escapeHtml(label)}</p>
<strong>${escapeHtml(value)}</strong>
</article>
`;
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

View File

@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Vista interna de la partida actual de Comunidad Hispana en HLL Vietnam."
/>
<title>Partida actual - HLL Vietnam</title>
<link rel="stylesheet" href="./assets/css/styles.css" />
<link rel="stylesheet" href="./assets/css/historico.css" />
</head>
<body data-backend-base-url="http://127.0.0.1:8000">
<div class="page-shell historical-shell">
<header class="hero historical-hero">
<div class="hero__overlay"></div>
<div class="hero__content historical-hero__content">
<div class="historical-hero__topline">
<a class="secondary-button secondary-button--ghost" href="./index.html">
VOLVER INICIO
</a>
<p class="eyebrow">Partida en vivo</p>
</div>
<div class="historical-hero__layout">
<div class="logo-frame historical-logo-frame">
<img
src="./assets/img/logo.png"
alt="Logo oficial de la comunidad HLL Vietnam"
class="logo-frame__image"
width="1024"
height="1044"
decoding="async"
/>
</div>
<div class="historical-hero__copy">
<div>
<h1 class="historical-hero__title" id="current-match-title">
Partida actual
</h1>
<p class="hero__text historical-hero__text" id="current-match-summary">
Cargando estado del servidor.
</p>
</div>
<div class="hero__actions">
<a class="secondary-button" id="current-match-history" href="./historico.html">
Abrir historico
</a>
<a
class="secondary-button"
id="current-match-scoreboard"
href="./index.html"
target="_blank"
rel="noreferrer"
hidden
>
Ver scoreboard publico
</a>
</div>
</div>
</div>
</div>
</header>
<main class="content historical-content">
<section class="panel historical-panel" aria-labelledby="current-match-panel-title">
<div class="panel__shell">
<div class="panel__header historical-panel__header">
<div>
<p class="eyebrow eyebrow--section">Estado en vivo</p>
<h2 id="current-match-panel-title">Marcador en curso</h2>
<p class="historical-panel__note" id="current-match-note">
Leyendo el ultimo snapshot disponible.
</p>
</div>
</div>
<p class="historical-state" id="current-match-state" aria-live="polite">
Cargando partida actual...
</p>
<div class="historical-summary-grid" id="current-match-grid" hidden></div>
</div>
</section>
<section class="panel historical-panel" aria-labelledby="current-match-feed-title">
<div class="panel__shell">
<div class="panel__header historical-panel__header">
<div>
<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.
</p>
</div>
</div>
</div>
</section>
<section class="panel historical-panel" aria-labelledby="current-match-players-title">
<div class="panel__shell">
<div class="panel__header historical-panel__header">
<div>
<p class="eyebrow eyebrow--section">Jugadores</p>
<h2 id="current-match-players-title">Estadisticas en vivo</h2>
<p class="historical-panel__note">
Las estadisticas en vivo apareceran cuando haya datos suficientes.
</p>
</div>
</div>
</div>
</section>
</main>
</div>
<script src="./assets/js/partida-actual.js"></script>
</body>
</html>