fix: project current match live data safely
This commit is contained in:
110
ai/tasks/done/TASK-153-fix-current-match-live-projection.md
Normal file
110
ai/tasks/done/TASK-153-fix-current-match-live-projection.md
Normal 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.
|
||||||
@@ -49,6 +49,7 @@ from .historical_storage import (
|
|||||||
)
|
)
|
||||||
from .rcon_historical_read_model import get_rcon_historical_match_detail
|
from .rcon_historical_read_model import get_rcon_historical_match_detail
|
||||||
from .normalizers import normalize_map_name
|
from .normalizers import normalize_map_name
|
||||||
|
from .rcon_client import load_rcon_targets, query_live_server_sample
|
||||||
from .rcon_admin_log_storage import list_current_match_kill_feed
|
from .rcon_admin_log_storage import list_current_match_kill_feed
|
||||||
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
from .scoreboard_origins import get_trusted_public_scoreboard_origin
|
||||||
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
from .storage import list_latest_snapshots, list_server_history, list_snapshot_history
|
||||||
@@ -244,6 +245,61 @@ def build_current_match_payload(*, server_slug: str) -> dict[str, object]:
|
|||||||
if origin is None:
|
if origin is None:
|
||||||
raise ValueError("Unsupported current match server.")
|
raise ValueError("Unsupported current match server.")
|
||||||
|
|
||||||
|
sample = _query_current_match_rcon_sample(origin.slug)
|
||||||
|
if sample is not None:
|
||||||
|
normalized = sample["normalized"]
|
||||||
|
raw_session = sample["raw_session"]
|
||||||
|
captured_at = _utc_timestamp_now()
|
||||||
|
map_id = raw_session.get("mapId") or normalized.get("current_map")
|
||||||
|
map_name = raw_session.get("mapName") or map_id
|
||||||
|
map_pretty_name = normalize_map_name(map_name)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"data": {
|
||||||
|
"found": True,
|
||||||
|
"server_slug": origin.slug,
|
||||||
|
"server_name": normalized.get("server_name") or origin.display_name,
|
||||||
|
"status": normalized.get("status") or "unavailable",
|
||||||
|
"map": map_pretty_name,
|
||||||
|
"map_id": map_id,
|
||||||
|
"map_pretty_name": map_pretty_name,
|
||||||
|
"game_mode": normalized.get("game_mode"),
|
||||||
|
"started_at": None,
|
||||||
|
"allied_score": normalized.get("allied_score"),
|
||||||
|
"axis_score": normalized.get("axis_score"),
|
||||||
|
"allied_players": normalized.get("allied_players"),
|
||||||
|
"axis_players": normalized.get("axis_players"),
|
||||||
|
"players": normalized.get("players"),
|
||||||
|
"max_players": normalized.get("max_players"),
|
||||||
|
# RCA: getSession currently reports 0 while the public scoreboard
|
||||||
|
# can show players, so session population is exposed but unverified.
|
||||||
|
"player_count_quality": (
|
||||||
|
"rcon-session-unverified"
|
||||||
|
if normalized.get("players") is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"player_count_source": _source_when_present(
|
||||||
|
normalized.get("players"),
|
||||||
|
source="rcon-session",
|
||||||
|
),
|
||||||
|
"score_source": _source_when_present(
|
||||||
|
normalized.get("allied_score"),
|
||||||
|
normalized.get("axis_score"),
|
||||||
|
source="rcon-session",
|
||||||
|
),
|
||||||
|
"map_source": _source_when_present(map_id, map_name, source="rcon-session"),
|
||||||
|
"match_time_seconds": normalized.get("match_time_seconds"),
|
||||||
|
"remaining_match_time_seconds": normalized.get(
|
||||||
|
"remaining_match_time_seconds"
|
||||||
|
),
|
||||||
|
"captured_at": captured_at,
|
||||||
|
"updated_at": captured_at,
|
||||||
|
"public_scoreboard_url": origin.base_url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# The generic live server snapshot is a fallback only. It intentionally
|
||||||
|
# drops richer RCON session fields such as game mode and current scores.
|
||||||
server_payload = build_servers_payload()
|
server_payload = build_servers_payload()
|
||||||
server_data = server_payload["data"]
|
server_data = server_payload["data"]
|
||||||
item = next(
|
item = next(
|
||||||
@@ -262,12 +318,31 @@ def build_current_match_payload(*, server_slug: str) -> dict[str, object]:
|
|||||||
"server_name": item.get("server_name") if item else origin.display_name,
|
"server_name": item.get("server_name") if item else origin.display_name,
|
||||||
"status": item.get("status") if item else "unavailable",
|
"status": item.get("status") if item else "unavailable",
|
||||||
"map": item.get("current_map") if item else None,
|
"map": item.get("current_map") if item else None,
|
||||||
|
"map_id": None,
|
||||||
|
"map_pretty_name": item.get("current_map") if item else None,
|
||||||
"game_mode": item.get("game_mode") if item else None,
|
"game_mode": item.get("game_mode") if item else None,
|
||||||
"started_at": item.get("started_at") if item else None,
|
"started_at": item.get("started_at") if item else None,
|
||||||
"allied_score": item.get("allied_score") if item else None,
|
"allied_score": item.get("allied_score") if item else None,
|
||||||
"axis_score": item.get("axis_score") if item else None,
|
"axis_score": item.get("axis_score") if item else None,
|
||||||
|
"allied_players": item.get("allied_players") if item else None,
|
||||||
|
"axis_players": item.get("axis_players") if item else None,
|
||||||
"players": item.get("players") if item else None,
|
"players": item.get("players") if item else None,
|
||||||
"max_players": item.get("max_players") if item else None,
|
"max_players": item.get("max_players") if item else None,
|
||||||
|
"player_count_quality": _snapshot_player_count_quality(item),
|
||||||
|
"player_count_source": _snapshot_player_count_source(item),
|
||||||
|
"score_source": _source_when_present(
|
||||||
|
item.get("allied_score") if item else None,
|
||||||
|
item.get("axis_score") if item else None,
|
||||||
|
source="live-server-snapshot",
|
||||||
|
),
|
||||||
|
"map_source": _source_when_present(
|
||||||
|
item.get("current_map") if item else None,
|
||||||
|
source="live-server-snapshot",
|
||||||
|
),
|
||||||
|
"match_time_seconds": item.get("match_time_seconds") if item else None,
|
||||||
|
"remaining_match_time_seconds": (
|
||||||
|
item.get("remaining_match_time_seconds") if item else None
|
||||||
|
),
|
||||||
"captured_at": item.get("captured_at") if item else None,
|
"captured_at": item.get("captured_at") if item else None,
|
||||||
"updated_at": server_data.get("last_snapshot_at"),
|
"updated_at": server_data.get("last_snapshot_at"),
|
||||||
"public_scoreboard_url": origin.base_url,
|
"public_scoreboard_url": origin.base_url,
|
||||||
@@ -295,6 +370,52 @@ def build_current_match_kill_feed_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None:
|
||||||
|
"""Read one configured trusted RCON target for the current-match view."""
|
||||||
|
try:
|
||||||
|
targets = load_rcon_targets()
|
||||||
|
except (RuntimeError, ValueError):
|
||||||
|
return None
|
||||||
|
target = next(
|
||||||
|
(candidate for candidate in targets if candidate.external_server_id == server_slug),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return query_live_server_sample(target)
|
||||||
|
except Exception: # noqa: BLE001 - fall back to the existing live snapshot read
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_timestamp_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _source_when_present(*values: object, source: str) -> str | None:
|
||||||
|
return source if any(value is not None for value in values) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_player_count_quality(item: dict[str, object] | None) -> str | None:
|
||||||
|
if item is None or item.get("players") is None:
|
||||||
|
return None
|
||||||
|
if item.get("snapshot_origin") == "real-rcon":
|
||||||
|
return "rcon-session-unverified"
|
||||||
|
if item.get("snapshot_origin") == "real-a2s":
|
||||||
|
return "a2s-query"
|
||||||
|
return "snapshot-unverified"
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_player_count_source(item: dict[str, object] | None) -> str | None:
|
||||||
|
if item is None or item.get("players") is None:
|
||||||
|
return None
|
||||||
|
if item.get("snapshot_origin") == "real-rcon":
|
||||||
|
return "rcon-session"
|
||||||
|
if item.get("snapshot_origin") == "real-a2s":
|
||||||
|
return "a2s"
|
||||||
|
return "live-server-snapshot"
|
||||||
|
|
||||||
|
|
||||||
def build_error_payload(message: str) -> dict[str, str]:
|
def build_error_payload(message: str) -> dict[str, str]:
|
||||||
"""Return the shared error payload shape used by the backend bootstrap."""
|
"""Return the shared error payload shape used by the backend bootstrap."""
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import re
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import get_storage_path, use_postgres_rcon_storage
|
from .config import get_storage_path, use_postgres_rcon_storage
|
||||||
@@ -15,6 +16,8 @@ from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot
|
|||||||
from .rcon_historical_storage import initialize_rcon_historical_storage
|
from .rcon_historical_storage import initialize_rcon_historical_storage
|
||||||
from .sqlite_utils import connect_sqlite_writer
|
from .sqlite_utils import connect_sqlite_writer
|
||||||
|
|
||||||
|
CURRENT_MATCH_FALLBACK_FRESHNESS = timedelta(minutes=15)
|
||||||
|
|
||||||
|
|
||||||
def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
|
def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
|
||||||
"""Create SQLite structures for parsed RCON AdminLog events."""
|
"""Create SQLite structures for parsed RCON AdminLog events."""
|
||||||
@@ -334,6 +337,7 @@ def list_current_match_kill_feed(
|
|||||||
server_key: str,
|
server_key: str,
|
||||||
limit: int = 30,
|
limit: int = 30,
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return safe recent kill rows for one AdminLog server window."""
|
"""Return safe recent kill rows for one AdminLog server window."""
|
||||||
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
||||||
@@ -396,9 +400,24 @@ def list_current_match_kill_feed(
|
|||||||
scope = "open-admin-log-match-window"
|
scope = "open-admin-log-match-window"
|
||||||
confidence = "admin-log-boundary"
|
confidence = "admin-log-boundary"
|
||||||
|
|
||||||
|
stale_events_filtered = 0
|
||||||
|
if scope == "recent-admin-log-window":
|
||||||
|
freshness_anchor = _as_utc_datetime(now) or datetime.now(timezone.utc)
|
||||||
|
fresh_rows = [
|
||||||
|
row
|
||||||
|
for row in rows
|
||||||
|
if _row_is_current_match_fallback_fresh(row, freshness_anchor)
|
||||||
|
]
|
||||||
|
stale_events_filtered = len(rows) - len(fresh_rows)
|
||||||
|
rows = fresh_rows
|
||||||
|
if not rows:
|
||||||
|
scope = "no-current-match-events"
|
||||||
|
confidence = "stale-filtered" if stale_events_filtered else "none"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
|
"stale_events_filtered": stale_events_filtered,
|
||||||
"items": [_serialize_kill_feed_row(row) for row in rows],
|
"items": [_serialize_kill_feed_row(row) for row in rows],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +543,32 @@ def _safe_event_field(value: object) -> str | None:
|
|||||||
return normalized or None
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _row_is_current_match_fallback_fresh(
|
||||||
|
row: Mapping[str, object],
|
||||||
|
freshness_anchor: datetime,
|
||||||
|
) -> bool:
|
||||||
|
event_time = _as_utc_datetime(row["event_timestamp"])
|
||||||
|
if event_time is None:
|
||||||
|
return False
|
||||||
|
age = freshness_anchor - event_time
|
||||||
|
return timedelta(0) <= age <= CURRENT_MATCH_FALLBACK_FRESHNESS
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc_datetime(value: object) -> datetime | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
parsed = value
|
||||||
|
elif isinstance(value, str) and value.strip():
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _persist_rcon_admin_log_entries_postgres(
|
def _persist_rcon_admin_log_entries_postgres(
|
||||||
*,
|
*,
|
||||||
target: Mapping[str, object],
|
target: Mapping[str, object],
|
||||||
|
|||||||
118
backend/tests/test_current_match_payload.py
Normal file
118
backend/tests/test_current_match_payload.py
Normal 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"]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import gc
|
import gc
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.rcon_admin_log_storage import (
|
from app.rcon_admin_log_storage import (
|
||||||
initialize_rcon_admin_log_storage,
|
initialize_rcon_admin_log_storage,
|
||||||
@@ -309,3 +310,61 @@ def test_current_match_kill_feed_prefers_open_match_window_and_normalizes_rows(t
|
|||||||
"is_teamkill": True,
|
"is_teamkill": True,
|
||||||
}
|
}
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_kill_feed_filters_stale_recent_fallback_rows(tmp_path):
|
||||||
|
db_path = tmp_path / "admin_log.sqlite3"
|
||||||
|
persist_rcon_admin_log_entries(
|
||||||
|
target=TARGET,
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T09:30:00Z",
|
||||||
|
"message": (
|
||||||
|
"[1:00 min (1779355800)] KILL: Old Killer(Allies/steam-old) -> "
|
||||||
|
"Old Victim(Axis/steam-victim-old) with M1 GARAND"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
feed = list_current_match_kill_feed(
|
||||||
|
server_key="test-rcon-target",
|
||||||
|
db_path=db_path,
|
||||||
|
now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert feed["scope"] == "no-current-match-events"
|
||||||
|
assert feed["confidence"] == "stale-filtered"
|
||||||
|
assert feed["stale_events_filtered"] == 1
|
||||||
|
assert feed["items"] == []
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_kill_feed_marks_fresh_recent_fallback_rows_partial(tmp_path):
|
||||||
|
db_path = tmp_path / "admin_log.sqlite3"
|
||||||
|
persist_rcon_admin_log_entries(
|
||||||
|
target=TARGET,
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T09:50:00Z",
|
||||||
|
"message": (
|
||||||
|
"[1:00 min (1779357000)] KILL: Fresh Killer(Allies/steam-fresh) -> "
|
||||||
|
"Fresh Victim(Axis/steam-victim-fresh) with M1 GARAND"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
feed = list_current_match_kill_feed(
|
||||||
|
server_key="test-rcon-target",
|
||||||
|
db_path=db_path,
|
||||||
|
now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert feed["scope"] == "recent-admin-log-window"
|
||||||
|
assert feed["confidence"] == "partial"
|
||||||
|
assert feed["stale_events_filtered"] == 0
|
||||||
|
assert [item["killer_name"] for item in feed["items"]] == ["Fresh Killer"]
|
||||||
|
gc.collect()
|
||||||
|
|||||||
@@ -88,6 +88,47 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-match-map-hero {
|
||||||
|
display: grid;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-map-hero > * {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-map-placeholder {
|
||||||
|
z-index: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: end;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(183, 201, 125, 0.12), transparent 44%),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
-28deg,
|
||||||
|
rgba(210, 182, 118, 0.06) 0 1px,
|
||||||
|
transparent 1px 20px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-map-placeholder strong {
|
||||||
|
font-size: 1.16rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-map-placeholder span {
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-scoreboard-message {
|
||||||
|
max-width: 12ch;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.3rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.historical-content {
|
.historical-content {
|
||||||
gap: 22px;
|
gap: 22px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ const CURRENT_MATCH_SERVERS = Object.freeze({
|
|||||||
"comunidad-hispana-01": "Comunidad Hispana #01",
|
"comunidad-hispana-01": "Comunidad Hispana #01",
|
||||||
"comunidad-hispana-02": "Comunidad Hispana #02",
|
"comunidad-hispana-02": "Comunidad Hispana #02",
|
||||||
});
|
});
|
||||||
|
const CURRENT_MATCH_SCOREBOARDS = Object.freeze({
|
||||||
|
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es",
|
||||||
|
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443",
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -16,6 +20,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
state: document.getElementById("current-match-state"),
|
state: document.getElementById("current-match-state"),
|
||||||
grid: document.getElementById("current-match-grid"),
|
grid: document.getElementById("current-match-grid"),
|
||||||
feedTitle: document.getElementById("current-match-feed-title"),
|
feedTitle: document.getElementById("current-match-feed-title"),
|
||||||
|
mapHero: document.getElementById("current-match-map-hero"),
|
||||||
|
mapImage: document.getElementById("current-match-map-image"),
|
||||||
|
mapPlaceholder: document.getElementById("current-match-map-placeholder"),
|
||||||
};
|
};
|
||||||
const backendBaseUrl =
|
const backendBaseUrl =
|
||||||
document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
|
document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
|
||||||
@@ -74,24 +81,17 @@ async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }
|
|||||||
|
|
||||||
function renderCurrentMatch(data, nodes) {
|
function renderCurrentMatch(data, nodes) {
|
||||||
const serverName = data.server_name || data.server_slug || "Servidor no disponible";
|
const serverName = data.server_name || data.server_slug || "Servidor no disponible";
|
||||||
const mapName = data.map || "Mapa no disponible";
|
const mapName = data.map_pretty_name || data.map || "Mapa no disponible";
|
||||||
nodes.title.textContent = serverName;
|
const scoreboardUrl = resolveTrustedScoreboardUrl(data);
|
||||||
nodes.summary.textContent = mapName;
|
nodes.title.textContent = mapName;
|
||||||
|
nodes.summary.textContent = serverName;
|
||||||
nodes.note.textContent = data.found
|
nodes.note.textContent = data.found
|
||||||
? "Snapshot en vivo recibido. La pagina se actualiza cada 30 segundos."
|
? "Lectura en vivo recibida. La pagina se actualiza cada 30 segundos."
|
||||||
: "Todavia no hay snapshot live disponible para este servidor.";
|
: "Todavia no hay snapshot live disponible para este servidor.";
|
||||||
nodes.scoreboard.href = data.public_scoreboard_url;
|
nodes.scoreboard.href = scoreboardUrl || "./index.html";
|
||||||
nodes.scoreboard.hidden = !data.public_scoreboard_url;
|
nodes.scoreboard.hidden = !scoreboardUrl;
|
||||||
nodes.grid.innerHTML = [
|
renderMapHero(data, mapName, nodes);
|
||||||
renderStat("Estado", formatStatus(data.status)),
|
nodes.grid.innerHTML = renderLiveScoreboard(data, { mapName, serverName });
|
||||||
renderStat("Mapa", mapName),
|
|
||||||
renderStat("Modo", data.game_mode || "No disponible"),
|
|
||||||
renderStat("Inicio", formatTimestamp(data.started_at)),
|
|
||||||
renderStat("Jugadores", formatPlayers(data.players, data.max_players)),
|
|
||||||
renderStat("Marcador aliado", formatScore(data.allied_score)),
|
|
||||||
renderStat("Marcador eje", formatScore(data.axis_score)),
|
|
||||||
renderStat("Actualizado", formatTimestamp(data.captured_at || data.updated_at)),
|
|
||||||
].join("");
|
|
||||||
nodes.state.hidden = true;
|
nodes.state.hidden = true;
|
||||||
nodes.grid.hidden = false;
|
nodes.grid.hidden = false;
|
||||||
}
|
}
|
||||||
@@ -103,6 +103,7 @@ function renderUnsupportedServer(nodes) {
|
|||||||
nodes.note.textContent = "";
|
nodes.note.textContent = "";
|
||||||
nodes.scoreboard.hidden = true;
|
nodes.scoreboard.hidden = true;
|
||||||
nodes.grid.hidden = true;
|
nodes.grid.hidden = true;
|
||||||
|
renderMapHero({}, "Mapa no disponible", nodes);
|
||||||
setState(nodes.state, "No se puede consultar la partida solicitada.", true);
|
setState(nodes.state, "No se puede consultar la partida solicitada.", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +129,9 @@ function initializeKillFeed(nodes) {
|
|||||||
|
|
||||||
function renderKillFeed(data, nodes, state) {
|
function renderKillFeed(data, nodes, state) {
|
||||||
const incoming = Array.isArray(data.items) ? data.items : [];
|
const incoming = Array.isArray(data.items) ? data.items : [];
|
||||||
|
if (data.scope === "no-current-match-events") {
|
||||||
|
state.byId.clear();
|
||||||
|
}
|
||||||
incoming.forEach((event) => {
|
incoming.forEach((event) => {
|
||||||
if (event?.event_id) {
|
if (event?.event_id) {
|
||||||
state.byId.set(event.event_id, event);
|
state.byId.set(event.event_id, event);
|
||||||
@@ -140,10 +144,7 @@ function renderKillFeed(data, nodes, state) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nodes.feedList.innerHTML = events.map(renderKillFeedRow).join("");
|
nodes.feedList.innerHTML = events.map(renderKillFeedRow).join("");
|
||||||
nodes.feedState.textContent =
|
nodes.feedState.textContent = formatKillFeedCoverage(data.scope);
|
||||||
data.scope === "open-admin-log-match-window"
|
|
||||||
? "Bajas del tramo abierto detectado por AdminLog."
|
|
||||||
: "Bajas recientes de AdminLog con cobertura parcial.";
|
|
||||||
nodes.feedState.classList.remove("historical-state--error");
|
nodes.feedState.classList.remove("historical-state--error");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,10 +189,10 @@ function formatEventTime(event) {
|
|||||||
: "Tiempo no disponible";
|
: "Tiempo no disponible";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStat(label, value) {
|
function renderCompactMeta(label, value) {
|
||||||
return `
|
return `
|
||||||
<article class="historical-stat-card">
|
<article>
|
||||||
<p>${escapeHtml(label)}</p>
|
<span>${escapeHtml(label)}</span>
|
||||||
<strong>${escapeHtml(value)}</strong>
|
<strong>${escapeHtml(value)}</strong>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
@@ -208,16 +209,12 @@ function formatStatus(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatPlayers(players, maxPlayers) {
|
function formatPlayers(players, maxPlayers) {
|
||||||
if (!Number.isFinite(Number(players)) || !Number.isFinite(Number(maxPlayers))) {
|
if (!isNumericValue(players) || !isNumericValue(maxPlayers)) {
|
||||||
return "No disponible";
|
return "No disponible";
|
||||||
}
|
}
|
||||||
return `${Number(players)} / ${Number(maxPlayers)}`;
|
return `${Number(players)} / ${Number(maxPlayers)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatScore(value) {
|
|
||||||
return Number.isFinite(Number(value)) ? String(Number(value)) : "No disponible";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(value) {
|
function formatTimestamp(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "No disponible";
|
return "No disponible";
|
||||||
@@ -232,6 +229,183 @@ function formatTimestamp(value) {
|
|||||||
}).format(timestamp);
|
}).format(timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderLiveScoreboard(data, { mapName, serverName }) {
|
||||||
|
const scoreKnown = hasKnownScore(data);
|
||||||
|
const scoreMarkup = scoreKnown
|
||||||
|
? `${Number(data.allied_score)} : ${Number(data.axis_score)}`
|
||||||
|
: "Marcador no disponible";
|
||||||
|
const scoreClass = scoreKnown ? "" : " current-match-scoreboard-message";
|
||||||
|
const metadata = [
|
||||||
|
["Servidor", serverName],
|
||||||
|
["Mapa", mapName],
|
||||||
|
["Modo", formatGameMode(data.game_mode)],
|
||||||
|
];
|
||||||
|
if (data.started_at) {
|
||||||
|
metadata.push(["Inicio", formatTimestamp(data.started_at)]);
|
||||||
|
}
|
||||||
|
const remainingTime = Number(data.remaining_match_time_seconds);
|
||||||
|
if (Number.isFinite(remainingTime) && remainingTime > 0) {
|
||||||
|
metadata.push(["Tiempo restante", formatDuration(remainingTime)]);
|
||||||
|
}
|
||||||
|
const matchTime = Number(data.match_time_seconds);
|
||||||
|
if (Number.isFinite(matchTime) && matchTime > 0) {
|
||||||
|
metadata.push(["Tiempo de partida", formatDuration(matchTime)]);
|
||||||
|
}
|
||||||
|
metadata.push(["Jugadores", formatPlayerCount(data)]);
|
||||||
|
metadata.push(["Actualizado", formatTimestamp(data.captured_at || data.updated_at)]);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<section class="historical-scoreboard-layout" aria-label="Marcador en vivo">
|
||||||
|
<div class="historical-scoreboard-layout__main">
|
||||||
|
${renderLiveSide("historical-scoreboard-side--allied", "Aliados", "./assets/img/factions/us.webp")}
|
||||||
|
<div class="historical-scoreboard-center">
|
||||||
|
<span class="historical-scoreboard-center__timer">${escapeHtml(formatStatus(data.status))}</span>
|
||||||
|
<strong class="historical-scoreboard-center__score${scoreClass}">${escapeHtml(scoreMarkup)}</strong>
|
||||||
|
<span class="historical-scoreboard-center__map">${escapeHtml(mapName)}</span>
|
||||||
|
<span class="historical-scoreboard-center__mode">${escapeHtml(formatGameMode(data.game_mode))}</span>
|
||||||
|
</div>
|
||||||
|
${renderLiveSide("historical-scoreboard-side--axis", "Eje", "./assets/img/factions/germany.webp")}
|
||||||
|
</div>
|
||||||
|
<div class="historical-scoreboard-layout__meta">
|
||||||
|
${metadata.map(([label, value]) => renderCompactMeta(label, value)).join("")}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLiveSide(sideClass, label, emblem) {
|
||||||
|
return `
|
||||||
|
<div class="historical-scoreboard-side ${sideClass}">
|
||||||
|
<img
|
||||||
|
class="historical-scoreboard-side__emblem"
|
||||||
|
src="${escapeHtml(emblem)}"
|
||||||
|
alt="${escapeHtml(label)}"
|
||||||
|
width="128"
|
||||||
|
height="128"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onerror="this.hidden = true; this.closest('.historical-scoreboard-side').classList.add('is-emblem-missing');"
|
||||||
|
/>
|
||||||
|
<div class="historical-scoreboard-side__text">
|
||||||
|
<strong>${escapeHtml(label)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMapHero(data, mapName, nodes) {
|
||||||
|
if (!nodes.mapImage || !nodes.mapPlaceholder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mapImagePath = resolveMapImagePath(data, mapName);
|
||||||
|
nodes.mapPlaceholder.hidden = Boolean(mapImagePath);
|
||||||
|
nodes.mapImage.hidden = !mapImagePath;
|
||||||
|
if (!mapImagePath) {
|
||||||
|
nodes.mapImage.removeAttribute("src");
|
||||||
|
nodes.mapImage.alt = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nodes.mapImage.src = mapImagePath;
|
||||||
|
nodes.mapImage.alt = mapName;
|
||||||
|
nodes.mapImage.onerror = () => {
|
||||||
|
nodes.mapImage.removeAttribute("src");
|
||||||
|
nodes.mapImage.hidden = true;
|
||||||
|
nodes.mapPlaceholder.hidden = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMapImagePath(data, mapName) {
|
||||||
|
const normalizedMap = normalizeLookupText(
|
||||||
|
`${data.map_id || ""} ${data.map || ""} ${data.map_pretty_name || ""} ${mapName || ""}`,
|
||||||
|
).replaceAll(" ", "");
|
||||||
|
const mapAssetByKey = {
|
||||||
|
carentan: "carentan-day.webp",
|
||||||
|
driel: "driel-day.webp",
|
||||||
|
elalamein: "elalamein-day.webp",
|
||||||
|
elsenbornridge: "elsenbornridge-day.webp",
|
||||||
|
foy: "foy-day.webp",
|
||||||
|
hill400: "hill400-day.webp",
|
||||||
|
hurtgenforest: "hurtgenforest-day.webp",
|
||||||
|
kharkov: "kharkov-day.webp",
|
||||||
|
kursk: "kursk-day.webp",
|
||||||
|
mortain: "mortain-day.webp",
|
||||||
|
omahabeach: "omahabeach-day.webp",
|
||||||
|
purpleheartlane: "purpleheartlane-rain.webp",
|
||||||
|
smolensk: "smolensk-day.webp",
|
||||||
|
stmariedumont: "stmariedumont-day.webp",
|
||||||
|
stmereeglise: "stmereeglise-day.webp",
|
||||||
|
tobrukdawn: "tobruk-dawn.webp",
|
||||||
|
tobruk: "tobruk-day.webp",
|
||||||
|
utahbeach: "utahbeach-day.webp",
|
||||||
|
};
|
||||||
|
const matchedKey = Object.keys(mapAssetByKey).find((key) =>
|
||||||
|
normalizedMap.includes(key),
|
||||||
|
);
|
||||||
|
return matchedKey ? `./assets/img/maps/${mapAssetByKey[matchedKey]}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTrustedScoreboardUrl(data) {
|
||||||
|
const trustedUrl = CURRENT_MATCH_SCOREBOARDS[data.server_slug];
|
||||||
|
return data.public_scoreboard_url === trustedUrl ? trustedUrl : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKnownScore(data) {
|
||||||
|
return isNumericValue(data.allied_score) && isNumericValue(data.axis_score);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPlayerCount(data) {
|
||||||
|
if (!isReliablePlayerCount(data.player_count_quality)) {
|
||||||
|
return "No verificado";
|
||||||
|
}
|
||||||
|
return formatPlayers(data.players, data.max_players);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReliablePlayerCount(quality) {
|
||||||
|
return quality === "reliable" || quality === "a2s-query";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumericValue(value) {
|
||||||
|
return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGameMode(value) {
|
||||||
|
if (!value) {
|
||||||
|
return "No disponible";
|
||||||
|
}
|
||||||
|
const normalized = String(value).replaceAll("_", " ").replaceAll("-", " ");
|
||||||
|
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(value) {
|
||||||
|
const seconds = Number(value);
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||||
|
return "No disponible";
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
return hours > 0 ? `${hours} h ${remainingMinutes} min` : `${minutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKillFeedCoverage(scope) {
|
||||||
|
if (scope === "open-admin-log-match-window") {
|
||||||
|
return "Bajas detectadas en la partida actual.";
|
||||||
|
}
|
||||||
|
if (scope === "recent-admin-log-window") {
|
||||||
|
return "Cobertura parcial desde AdminLog reciente.";
|
||||||
|
}
|
||||||
|
return "Todavia no se han detectado bajas en esta partida.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLookupText(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function setState(node, message, isError = false) {
|
function setState(node, message, isError = false) {
|
||||||
node.textContent = message;
|
node.textContent = message;
|
||||||
node.hidden = false;
|
node.hidden = false;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<title>Partida actual - HLL Vietnam</title>
|
<title>Partida actual - HLL Vietnam</title>
|
||||||
<link rel="stylesheet" href="./assets/css/styles.css" />
|
<link rel="stylesheet" href="./assets/css/styles.css" />
|
||||||
<link rel="stylesheet" href="./assets/css/historico.css" />
|
<link rel="stylesheet" href="./assets/css/historico.css" />
|
||||||
|
<link rel="stylesheet" href="./assets/css/historico-scoreboard-detail.css" />
|
||||||
</head>
|
</head>
|
||||||
<body data-backend-base-url="http://127.0.0.1:8000">
|
<body data-backend-base-url="http://127.0.0.1:8000">
|
||||||
<div class="page-shell historical-shell">
|
<div class="page-shell historical-shell">
|
||||||
@@ -58,6 +59,23 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<figure class="historical-map-hero current-match-map-hero" id="current-match-map-hero">
|
||||||
|
<img
|
||||||
|
class="historical-map-hero__image"
|
||||||
|
id="current-match-map-image"
|
||||||
|
src=""
|
||||||
|
alt=""
|
||||||
|
width="960"
|
||||||
|
height="540"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
|
<div class="current-match-map-placeholder" id="current-match-map-placeholder">
|
||||||
|
<strong>Mapa en vivo</strong>
|
||||||
|
<span>Esperando imagen disponible.</span>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -88,7 +106,7 @@
|
|||||||
<p class="eyebrow eyebrow--section">Combate</p>
|
<p class="eyebrow eyebrow--section">Combate</p>
|
||||||
<h2 id="current-match-feed-title">Feed de combate</h2>
|
<h2 id="current-match-feed-title">Feed de combate</h2>
|
||||||
<p class="historical-panel__note">
|
<p class="historical-panel__note">
|
||||||
Los eventos de bajas en vivo apareceran aqui cuando se habiliten.
|
Leyendo eventos recientes con cobertura segura para esta partida.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user