feat: add current match page and live kill feed
This commit is contained in:
@@ -18,3 +18,4 @@ Date | Task | Duration | Result | Notes
|
||||
2026-05-19T08:39:12 | worker-cycle | 663.71 sec | success | codex-runner
|
||||
2026-05-19T17:20:44 | worker-cycle | 2240.22 sec | success | codex-runner
|
||||
2026-05-21T14:43:44 | worker-cycle | 5580.9 sec | success | codex-runner
|
||||
2026-05-21T15:17:18 | worker-cycle | 688.86 sec | success | codex-runner
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: TASK-150
|
||||
title: Server card history and current match entrypoints
|
||||
status: pending
|
||||
status: done
|
||||
type: frontend
|
||||
team: Frontend Senior
|
||||
supporting_teams: []
|
||||
@@ -164,9 +164,24 @@ historical page can open directly filtered by server.
|
||||
|
||||
## Outcome
|
||||
|
||||
Document the validation performed, URL trust boundary decisions, historical
|
||||
filter initialization behavior, and any follow-up task instead of expanding
|
||||
scope.
|
||||
- Replaced the home server-card history URL path with a trusted frontend action
|
||||
catalog for active servers `comunidad-hispana-01` and
|
||||
`comunidad-hispana-02` only. Payload-provided `community_history_url`
|
||||
values and the previous server #03 fallback are no longer used for those
|
||||
card actions.
|
||||
- Added the three per-server entrypoints from trusted constants: public
|
||||
scoreboard base URL, internal historical filter URL and internal current
|
||||
match URL.
|
||||
- Confirmed `frontend/assets/js/historico.js` already normalizes the supported
|
||||
`?server=` values into the active selector and falls back to `all-servers`
|
||||
for unknown or missing values, so no historical page code change was needed.
|
||||
- Added a minimal `partida-actual.html` placeholder with a trusted server label
|
||||
map and safe internal links for TASK-151 to upgrade.
|
||||
- Validation: `node --check frontend/assets/js/main.js`.
|
||||
- Scope review: `git diff --name-only` and `git status --short` were reviewed
|
||||
for the task move plus frontend files. Browser click verification was not
|
||||
completed because the in-app browser JavaScript control tool was not
|
||||
available after tool discovery in this session.
|
||||
|
||||
## Change Budget
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: TASK-151
|
||||
title: Current match page base
|
||||
status: pending
|
||||
status: done
|
||||
type: frontend
|
||||
team: Frontend Senior
|
||||
supporting_teams:
|
||||
@@ -159,8 +159,29 @@ live server/match state without pretending the match is closed.
|
||||
|
||||
## Outcome
|
||||
|
||||
Document the live-state source, polling behavior, validation performed, and any
|
||||
follow-up task instead of expanding scope.
|
||||
- Upgraded `partida-actual.html` into the first internal live match page and
|
||||
kept it aligned with the existing historical shell/styles.
|
||||
- Added frontend polling every 30 seconds with an in-flight guard. The page
|
||||
rejects unknown `?server=` values before building any external link, and the
|
||||
public scoreboard button is populated only from the trusted backend
|
||||
projection.
|
||||
- Added read-only `GET /api/current-match?server=`. It supports only active
|
||||
trusted scoreboard origins and projects the existing live server snapshot
|
||||
fields into the current-match shape. The current live snapshot persistence
|
||||
exposes status, map, population and capture time; score, game mode and match
|
||||
start fields remain `null`/unavailable when the snapshot source does not
|
||||
provide them.
|
||||
- Kept the combat feed and live player statistics as honest empty placeholders
|
||||
for the follow-up tasks rather than fabricating closed-match or kill data.
|
||||
- Validation: `python -m compileall backend/app`;
|
||||
`node --check frontend/assets/js/partida-actual.js`;
|
||||
`powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`;
|
||||
narrow inline route guard check for missing/unknown current-match server
|
||||
values; narrow inline payload projection check using a controlled live
|
||||
snapshot document.
|
||||
- Scope review: `git diff --name-only` and `git status --short` were reviewed.
|
||||
No focused product route test file exists in `backend/tests` for this API
|
||||
bootstrap layer yet.
|
||||
|
||||
## Change Budget
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: TASK-152
|
||||
title: Current match live kill feed
|
||||
status: pending
|
||||
status: done
|
||||
type: backend
|
||||
team: Backend Senior
|
||||
supporting_teams:
|
||||
@@ -139,8 +139,33 @@ feed based on RCON/AdminLog data.
|
||||
|
||||
## Outcome
|
||||
|
||||
Document the feed scope and confidence behavior, event normalization choices,
|
||||
polling validation, and any follow-up task instead of expanding scope.
|
||||
- Added read-only `GET /api/current-match/kills?server=&limit=` for the active
|
||||
trusted Comunidad Hispana servers. Unsupported servers fail before any
|
||||
AdminLog query is built.
|
||||
- Added a safe AdminLog kill feed read that emits normalized fields only:
|
||||
generated `event_id`, event time/server time, killer/victim names and teams,
|
||||
weapon, and computed `is_teamkill`. It does not return raw AdminLog text,
|
||||
player ids or admin-only payload fields.
|
||||
- Feed scope prefers `open-admin-log-match-window` with
|
||||
`confidence: "admin-log-boundary"` when the latest AdminLog boundary for the
|
||||
server is an unmatched `match_start`. Otherwise it returns
|
||||
`recent-admin-log-window` with `confidence: "partial"` as the explicit
|
||||
fallback.
|
||||
- Extended `partida-actual.js` to poll the feed with the existing 30-second
|
||||
in-flight-protected current-match refresh cycle, dedupe rows by `event_id`,
|
||||
keep newest events ordered by server/event time, render a TK badge for
|
||||
teamkills, and show empty/error states without fake rows.
|
||||
- Added a focused AdminLog storage test for open-window filtering and teamkill
|
||||
normalization. The environment does not have `pytest` installed (`pytest`
|
||||
and `python -m pytest` both failed), so the same storage normalization path
|
||||
was verified with a narrow inline Python scenario.
|
||||
- Validation: `python -m compileall backend/app`;
|
||||
`node --check frontend/assets/js/partida-actual.js`;
|
||||
`powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`;
|
||||
narrow inline route guard check for missing, unknown and invalid-limit feed
|
||||
requests; inline storage normalization check excluding pre-window kills and
|
||||
confirming no raw message field leaks.
|
||||
- Scope review: `git diff --name-only` and `git status --short` were reviewed.
|
||||
|
||||
## Change Budget
|
||||
|
||||
@@ -49,6 +49,7 @@ from .historical_storage import (
|
||||
)
|
||||
from .rcon_historical_read_model import get_rcon_historical_match_detail
|
||||
from .normalizers import normalize_map_name
|
||||
from .rcon_admin_log_storage import list_current_match_kill_feed
|
||||
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
||||
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
||||
|
||||
@@ -237,6 +238,63 @@ def build_server_detail_history_payload(
|
||||
}
|
||||
|
||||
|
||||
def build_current_match_payload(*, server_slug: str) -> dict[str, object]:
|
||||
"""Return the live page projection for one trusted active server."""
|
||||
origin = get_trusted_public_scoreboard_origin(server_slug)
|
||||
if origin is None:
|
||||
raise ValueError("Unsupported current match server.")
|
||||
|
||||
server_payload = build_servers_payload()
|
||||
server_data = server_payload["data"]
|
||||
item = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in server_data.get("items", [])
|
||||
if candidate.get("external_server_id") == origin.slug
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"found": item is not None,
|
||||
"server_slug": origin.slug,
|
||||
"server_name": item.get("server_name") if item else origin.display_name,
|
||||
"status": item.get("status") if item else "unavailable",
|
||||
"map": item.get("current_map") if item else None,
|
||||
"game_mode": item.get("game_mode") if item else None,
|
||||
"started_at": item.get("started_at") if item else None,
|
||||
"allied_score": item.get("allied_score") if item else None,
|
||||
"axis_score": item.get("axis_score") if item else None,
|
||||
"players": item.get("players") if item else None,
|
||||
"max_players": item.get("max_players") if item else None,
|
||||
"captured_at": item.get("captured_at") if item else None,
|
||||
"updated_at": server_data.get("last_snapshot_at"),
|
||||
"public_scoreboard_url": origin.base_url,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_current_match_kill_feed_payload(
|
||||
*,
|
||||
server_slug: str,
|
||||
limit: int = 30,
|
||||
) -> dict[str, object]:
|
||||
"""Return normalized AdminLog kill rows for one trusted current-match page."""
|
||||
origin = get_trusted_public_scoreboard_origin(server_slug)
|
||||
if origin is None:
|
||||
raise ValueError("Unsupported current match server.")
|
||||
feed = list_current_match_kill_feed(server_key=origin.slug, limit=limit)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"server_slug": origin.slug,
|
||||
"server_name": origin.display_name,
|
||||
**feed,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_error_payload(message: str) -> dict[str, str]:
|
||||
"""Return the shared error payload shape used by the backend bootstrap."""
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Mapping
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_storage_path, use_postgres_rcon_storage
|
||||
@@ -328,6 +329,80 @@ def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dic
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def list_current_match_kill_feed(
|
||||
*,
|
||||
server_key: str,
|
||||
limit: int = 30,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return safe recent kill rows for one AdminLog server window."""
|
||||
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
||||
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||
from .postgres_rcon_storage import connect_postgres_compat
|
||||
|
||||
connection_scope = connect_postgres_compat()
|
||||
else:
|
||||
connection_scope = closing(sqlite3.connect(resolved_path))
|
||||
|
||||
with connection_scope as connection:
|
||||
if isinstance(connection, sqlite3.Connection):
|
||||
connection.row_factory = sqlite3.Row
|
||||
boundary = connection.execute(
|
||||
"""
|
||||
SELECT event_type, server_time
|
||||
FROM rcon_admin_log_events
|
||||
WHERE (target_key = ? OR external_server_id = ?)
|
||||
AND event_type IN ('match_start', 'match_end')
|
||||
AND server_time IS NOT NULL
|
||||
ORDER BY server_time DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(server_key, server_key),
|
||||
).fetchone()
|
||||
open_start_time = (
|
||||
boundary["server_time"]
|
||||
if boundary is not None and boundary["event_type"] == "match_start"
|
||||
else None
|
||||
)
|
||||
if open_start_time is None:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id, target_key, external_server_id, event_timestamp, server_time,
|
||||
parsed_payload_json
|
||||
FROM rcon_admin_log_events
|
||||
WHERE (target_key = ? OR external_server_id = ?)
|
||||
AND event_type = 'kill'
|
||||
ORDER BY server_time DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(server_key, server_key, limit),
|
||||
).fetchall()
|
||||
scope = "recent-admin-log-window"
|
||||
confidence = "partial"
|
||||
else:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id, target_key, external_server_id, event_timestamp, server_time,
|
||||
parsed_payload_json
|
||||
FROM rcon_admin_log_events
|
||||
WHERE (target_key = ? OR external_server_id = ?)
|
||||
AND event_type = 'kill'
|
||||
AND server_time >= ?
|
||||
ORDER BY server_time DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(server_key, server_key, open_start_time, limit),
|
||||
).fetchall()
|
||||
scope = "open-admin-log-match-window"
|
||||
confidence = "admin-log-boundary"
|
||||
|
||||
return {
|
||||
"scope": scope,
|
||||
"confidence": confidence,
|
||||
"items": [_serialize_kill_feed_row(row) for row in rows],
|
||||
}
|
||||
|
||||
|
||||
def get_latest_rcon_player_profile_summaries(
|
||||
*,
|
||||
target_key: str,
|
||||
@@ -422,6 +497,33 @@ def _json_mapping(raw_value: object) -> dict[str, object]:
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _serialize_kill_feed_row(row: Mapping[str, object]) -> dict[str, object]:
|
||||
payload = _json_mapping(row["parsed_payload_json"])
|
||||
target_key = str(row["external_server_id"] or row["target_key"] or "unknown")
|
||||
killer_team = _safe_event_field(payload.get("killer_team"))
|
||||
victim_team = _safe_event_field(payload.get("victim_team"))
|
||||
return {
|
||||
"event_id": f"rcon-admin-log:{target_key}:{row['id']}",
|
||||
"event_timestamp": row["event_timestamp"],
|
||||
"server_time": row["server_time"],
|
||||
"killer_name": _safe_event_field(payload.get("killer_name")),
|
||||
"killer_team": killer_team,
|
||||
"victim_name": _safe_event_field(payload.get("victim_name")),
|
||||
"victim_team": victim_team,
|
||||
"weapon": _safe_event_field(payload.get("weapon")),
|
||||
"is_teamkill": bool(
|
||||
killer_team
|
||||
and killer_team != "None"
|
||||
and killer_team == victim_team
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _safe_event_field(value: object) -> str | None:
|
||||
normalized = str(value or "").strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _persist_rcon_admin_log_entries_postgres(
|
||||
*,
|
||||
target: Mapping[str, object],
|
||||
|
||||
@@ -7,6 +7,8 @@ from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from .payloads import (
|
||||
build_community_payload,
|
||||
build_current_match_kill_feed_payload,
|
||||
build_current_match_payload,
|
||||
build_discord_payload,
|
||||
build_elo_mmr_leaderboard_payload,
|
||||
build_elo_mmr_player_payload,
|
||||
@@ -37,6 +39,7 @@ from .payloads import (
|
||||
build_weekly_leaderboard_payload,
|
||||
build_weekly_top_kills_payload,
|
||||
)
|
||||
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
||||
|
||||
|
||||
GET_ROUTES = {
|
||||
@@ -60,6 +63,28 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||
return HTTPStatus.OK, build_server_history_payload(limit=limit)
|
||||
|
||||
if parsed.path == "/api/current-match":
|
||||
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||
if not server_slug:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
|
||||
if get_trusted_public_scoreboard_origin(server_slug) is None:
|
||||
return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported")
|
||||
return HTTPStatus.OK, build_current_match_payload(server_slug=server_slug)
|
||||
|
||||
if parsed.path == "/api/current-match/kills":
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||
if not server_slug:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
|
||||
if get_trusted_public_scoreboard_origin(server_slug) is None:
|
||||
return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported")
|
||||
return HTTPStatus.OK, build_current_match_kill_feed_payload(
|
||||
server_slug=server_slug,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if parsed.path == "/api/historical/weekly-top-kills":
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
|
||||
@@ -4,6 +4,7 @@ import sqlite3
|
||||
|
||||
from app.rcon_admin_log_storage import (
|
||||
initialize_rcon_admin_log_storage,
|
||||
list_current_match_kill_feed,
|
||||
list_rcon_admin_log_event_counts,
|
||||
persist_rcon_admin_log_entries,
|
||||
)
|
||||
@@ -259,3 +260,52 @@ def test_list_rcon_admin_log_event_counts_groups_by_target_and_event_type(tmp_pa
|
||||
},
|
||||
}
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_current_match_kill_feed_prefers_open_match_window_and_normalizes_rows(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-19T09:59:00Z",
|
||||
"message": (
|
||||
"[0:59 min (90)] KILL: Old Killer(Allies/steam-old) -> "
|
||||
"Old Victim(Axis/steam-victim-old) with M1 GARAND"
|
||||
),
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-19T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-19T10:01:00Z",
|
||||
"message": (
|
||||
"[2:00 min (120)] KILL: Alpha(Allies/steam-alpha) -> "
|
||||
"Bravo(Allies/steam-bravo) with GRENADE"
|
||||
),
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
feed = list_current_match_kill_feed(
|
||||
server_key="test-rcon-target",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert feed["scope"] == "open-admin-log-match-window"
|
||||
assert feed["confidence"] == "admin-log-boundary"
|
||||
assert len(feed["items"]) == 1
|
||||
assert feed["items"][0] == {
|
||||
"event_id": "rcon-admin-log:test-rcon-target:3",
|
||||
"event_timestamp": "2026-05-19T10:01:00Z",
|
||||
"server_time": 120,
|
||||
"killer_name": "Alpha",
|
||||
"killer_team": "Allies",
|
||||
"victim_name": "Bravo",
|
||||
"victim_team": "Allies",
|
||||
"weapon": "GRENADE",
|
||||
"is_teamkill": True,
|
||||
}
|
||||
gc.collect()
|
||||
|
||||
@@ -671,6 +671,8 @@ h2 {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// Progressive enhancement for local frontend-backend checks.
|
||||
const DEFAULT_SERVER_POLL_INTERVAL_MS = 300 * 1000;
|
||||
const SERVER_HISTORY_URLS_FALLBACK = Object.freeze({
|
||||
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es/games",
|
||||
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443/games",
|
||||
"comunidad-hispana-03": "https://scoreboard.comunidadhll.es:3443/games",
|
||||
const TRUSTED_SERVER_ACTIONS = Object.freeze({
|
||||
"comunidad-hispana-01": Object.freeze({
|
||||
publicScoreboardUrl: "https://scoreboard.comunidadhll.es",
|
||||
historicalUrl: "./historico.html?server=comunidad-hispana-01",
|
||||
currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-01",
|
||||
}),
|
||||
"comunidad-hispana-02": Object.freeze({
|
||||
publicScoreboardUrl: "https://scoreboard.comunidadhll.es:5443",
|
||||
historicalUrl: "./historico.html?server=comunidad-hispana-02",
|
||||
currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-02",
|
||||
}),
|
||||
});
|
||||
const COMMUNITY_CLANS = Object.freeze([
|
||||
{
|
||||
@@ -328,22 +335,8 @@ function normalizeServerRegion(value) {
|
||||
}
|
||||
|
||||
function renderServerAction(server) {
|
||||
const historyState = getServerHistoryState(server);
|
||||
if (!historyState.available) {
|
||||
return `
|
||||
<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) {
|
||||
const actions = getTrustedServerActions(server);
|
||||
if (!actions) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -351,11 +344,17 @@ function renderServerAction(server) {
|
||||
<div class="server-card__actions">
|
||||
<a
|
||||
class="server-action-link"
|
||||
href="${escapeHtml(historyUrl)}"
|
||||
href="${escapeHtml(actions.publicScoreboardUrl)}"
|
||||
target="_blank"
|
||||
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>
|
||||
</div>
|
||||
`;
|
||||
@@ -458,28 +457,12 @@ function renderQuickFacts(items) {
|
||||
`;
|
||||
}
|
||||
|
||||
function getServerHistoryUrl(server) {
|
||||
if (typeof server?.community_history_url === "string" && server.community_history_url.trim()) {
|
||||
return server.community_history_url.trim();
|
||||
}
|
||||
|
||||
function getTrustedServerActions(server) {
|
||||
const externalServerId =
|
||||
typeof server?.external_server_id === "string"
|
||||
? server.external_server_id.trim()
|
||||
: "";
|
||||
if (!externalServerId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return SERVER_HISTORY_URLS_FALLBACK[externalServerId] || "";
|
||||
}
|
||||
|
||||
function getServerHistoryState(server) {
|
||||
const historyUrl = getServerHistoryUrl(server);
|
||||
return {
|
||||
available: Boolean(historyUrl),
|
||||
url: historyUrl,
|
||||
};
|
||||
return TRUSTED_SERVER_ACTIONS[externalServerId] || null;
|
||||
}
|
||||
|
||||
function selectPrimaryServerItems(items) {
|
||||
|
||||
256
frontend/assets/js/partida-actual.js
Normal file
256
frontend/assets/js/partida-actual.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
115
frontend/partida-actual.html
Normal file
115
frontend/partida-actual.html
Normal 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>
|
||||
Reference in New Issue
Block a user