const CURRENT_MATCH_POLL_INTERVAL_MS = 30 * 1000; const CURRENT_MATCH_KILL_FEED_POLL_INTERVAL_MS = 1500; const CURRENT_MATCH_PLAYER_STATS_POLL_INTERVAL_MS = 3000; const CURRENT_MATCH_SERVERS = Object.freeze({ "comunidad-hispana-01": "Comunidad Hispana #01", "comunidad-hispana-02": "Comunidad Hispana #02", }); const CURRENT_MATCH_SCOREBOARDS = Object.freeze({ "comunidad-hispana-01": "https://scoreboard.comunidadhll.es", "comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443", }); const CURRENT_MATCH_KILL_FEED_LIMIT = 18; const CURRENT_MATCH_KILL_FEED_VISIBLE_LIMIT_DESKTOP = 12; const CURRENT_MATCH_KILL_FEED_VISIBLE_LIMIT_MEDIUM = 6; const CURRENT_MATCH_KILL_FEED_VISIBLE_LIMIT_MOBILE = 5; 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"), playersTitle: document.getElementById("current-match-players-title"), mapHero: document.getElementById("current-match-map-hero"), mapImage: document.getElementById("current-match-map-image"), mapPlaceholder: document.getElementById("current-match-map-placeholder"), }; const backendBaseUrl = document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; if (!CURRENT_MATCH_SERVERS[serverSlug]) { renderUnsupportedServer(nodes); return; } configureOptionalLinks(nodes, serverSlug); const killFeedState = initializeKillFeed(nodes); const playerStatsState = initializePlayerStats(nodes); let currentMatchRefreshInFlight = false; const refreshCurrentMatch = async () => { if (currentMatchRefreshInFlight) { return; } currentMatchRefreshInFlight = true; try { await loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }); } finally { currentMatchRefreshInFlight = false; } }; let killFeedRefreshInFlight = false; const refreshKillFeed = async () => { if (killFeedRefreshInFlight) { return; } killFeedRefreshInFlight = true; try { await loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }); } finally { killFeedRefreshInFlight = false; } }; let playerStatsRefreshInFlight = false; const refreshPlayerStats = async () => { if (playerStatsRefreshInFlight) { return; } playerStatsRefreshInFlight = true; try { await loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }); } finally { playerStatsRefreshInFlight = false; } }; void refreshCurrentMatch(); void refreshKillFeed(); void refreshPlayerStats(); window.setInterval(() => { void refreshCurrentMatch(); }, CURRENT_MATCH_POLL_INTERVAL_MS); window.setInterval(() => { void refreshKillFeed(); }, CURRENT_MATCH_KILL_FEED_POLL_INTERVAL_MS); window.setInterval(() => { void refreshPlayerStats(); }, CURRENT_MATCH_PLAYER_STATS_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) { if (nodes.note) { nodes.note.textContent = "Se conserva el ultimo estado visible si estaba disponible."; } setState(nodes.state, "No se pudo actualizar la partida actual.", true); } } function configureOptionalLinks(nodes, serverSlug) { setOptionalHref( nodes.history, `./historico.html?server=${encodeURIComponent(serverSlug)}`, ); } async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) { try { const payload = await fetchJson( `${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=${CURRENT_MATCH_KILL_FEED_LIMIT}`, ); renderKillFeed(payload?.data || {}, nodes, killFeedState); } catch (error) { setState(nodes.feedState, "No se pudo actualizar el feed de combate.", true); } } async function loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }) { try { const payload = await fetchJson( `${backendBaseUrl}/api/current-match/players?server=${encodeURIComponent(serverSlug)}`, ); renderPlayerStats(payload?.data || {}, nodes, playerStatsState); } catch (error) { setState( nodes.playerStatsState, "Todavía no hay estadísticas fiables de jugadores para esta partida.", true, ); } } function renderCurrentMatch(data, nodes) { const rawServerName = data.server_name || data.server_slug || "Servidor no disponible"; const serverName = formatServerDisplayName(data, rawServerName); const mapName = data.map_pretty_name || data.map || "Mapa no disponible"; const scoreboardUrl = resolveTrustedScoreboardUrl(data); nodes.title.textContent = mapName; nodes.summary.textContent = serverName; nodes.note.textContent = data.found ? "Lectura en vivo recibida. El feed de bajas se actualiza en tiempo casi real." : "Todavia no hay snapshot live disponible para este servidor."; setOptionalHref(nodes.scoreboard, scoreboardUrl || "./index.html"); setOptionalHidden(nodes.scoreboard, !scoreboardUrl); renderMapHero(data, mapName, nodes); nodes.grid.innerHTML = renderLiveScoreboard(data, { mapName, serverName }); 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 = ""; setOptionalHidden(nodes.scoreboard, true); nodes.grid.hidden = true; renderMapHero({}, "Mapa no disponible", nodes); 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", `
Cargando feed de combate...
Cargando estadisticas en vivo...
`, ); } nodes.playerStatsState = document.getElementById("current-match-player-stats-state"); nodes.playerCount = document.getElementById("current-match-player-count"); nodes.playerStatsShell = document.getElementById("current-match-player-stats-shell"); return { visibleSignature: "", }; } function renderKillFeed(data, nodes, state) { if (!nodes.feedList || !nodes.feedState) { return; } const incoming = Array.isArray(data.items) ? data.items.filter(isValidKillFeedEvent) : []; const contextSignature = getKillFeedContextSignature(data); const hasStoredEvents = state.byId.size > 0; const contextChanged = Boolean(contextSignature) && Boolean(state.contextSignature) && contextSignature !== state.contextSignature; if (incoming.length > 0) { const events = buildKillFeedWindow(incoming) .sort(compareKillFeedEvents) .slice(-CURRENT_MATCH_KILL_FEED_LIMIT); replaceKillFeedWindow(state, events, contextSignature); renderKillFeedEvents({ data, events, nodes, state, }); return; } if (!hasStoredEvents || contextChanged) { replaceKillFeedWindow(state, [], contextSignature || state.contextSignature); renderKillFeedEvents({ data, events: [], nodes, state, }); return; } const events = [...state.byId.values()] .sort(compareKillFeedEvents) .slice(-CURRENT_MATCH_KILL_FEED_LIMIT); replaceKillFeedWindow(state, events, state.contextSignature); renderKillFeedEvents({ data: { ...data, scope: "preserved-recent-window" }, events, nodes, state, }); } function renderKillFeedEvents({ data, events, nodes, state }) { if (events.length === 0) { nodes.feedList.innerHTML = ""; state.visibleSignature = ""; state.visibleCount = 0; setState(nodes.feedState, "Todavía no se han detectado bajas en esta partida."); return; } const visibleLimit = getKillFeedVisibleLimit(); const visualEvents = events.slice(-visibleLimit); const visibleSignature = getKillFeedVisibleSignature(visualEvents, visibleLimit); if (visibleSignature !== state.visibleSignature) { nodes.feedList.innerHTML = renderKillFeedColumns(visualEvents); state.visibleSignature = visibleSignature; state.visibleCount = visualEvents.length; } nodes.feedState.textContent = formatKillFeedCoverage( data.scope, visualEvents.length, events.length, ); nodes.feedState.classList.remove("historical-state--error"); } function replaceKillFeedWindow(state, events, contextSignature) { state.byId = new Map( events.map((event) => [getKillFeedWindowKey(event), event]), ); state.latestEventId = events[events.length - 1]?.event_id || ""; state.contextSignature = contextSignature || state.contextSignature; } function buildKillFeedWindow(events) { const deduped = []; const indexByEventId = new Map(); const indexBySemanticKey = new Map(); events.forEach((event) => { const eventIdKey = getKillFeedEventIdKey(event); const semanticKey = getKillFeedSemanticKey(event); const existingIndex = (eventIdKey ? indexByEventId.get(eventIdKey) : undefined) ?? (semanticKey ? indexBySemanticKey.get(semanticKey) : undefined); if (existingIndex !== undefined) { deduped[existingIndex] = pickBetterKillFeedEvent( deduped[existingIndex], event, ); if (eventIdKey) { indexByEventId.set(eventIdKey, existingIndex); } if (semanticKey) { indexBySemanticKey.set(semanticKey, existingIndex); } return; } const nextIndex = deduped.length; deduped.push(event); if (eventIdKey) { indexByEventId.set(eventIdKey, nextIndex); } if (semanticKey) { indexBySemanticKey.set(semanticKey, nextIndex); } }); return deduped; } function pickBetterKillFeedEvent(current, candidate) { if (!current) { return candidate; } return getKillFeedMetadataScore(candidate) > getKillFeedMetadataScore(current) ? candidate : current; } function getKillFeedMetadataScore(event) { return [ isKnownKillFeedTeam(event?.killer_team), isKnownKillFeedTeam(event?.victim_team), isAvailableKillFeedName(event?.killer_name, "Jugador no disponible"), isAvailableKillFeedName(event?.victim_name, "Objetivo no disponible"), Boolean(String(event?.weapon || "").trim()), Boolean(String(event?.event_timestamp || "").trim()), Number.isFinite(Number(event?.server_time)), Boolean(String(event?.event_id || "").trim()), ].filter(Boolean).length; } function isValidKillFeedEvent(event) { return Boolean( event && typeof event === "object" && (getKillFeedEventIdKey(event) || getKillFeedSemanticKey(event)), ); } function getKillFeedWindowKey(event) { return getKillFeedSemanticKey(event) || getKillFeedEventIdKey(event); } function getKillFeedEventIdKey(event) { const eventId = String(event?.event_id || "").trim(); return eventId ? `event:${eventId}` : ""; } function getKillFeedSemanticKey(event) { const parts = [ event?.server_time, event?.event_timestamp, event?.killer_name, event?.victim_name, event?.weapon, ].map((value) => normalizeKillFeedDedupeValue(value)); return parts.every(Boolean) ? `semantic:${parts.join("|")}` : ""; } function getKillFeedVisibleSignature(events, visibleLimit) { return `${visibleLimit}:${events .map((event) => [ getKillFeedWindowKey(event), normalizeKillFeedDedupeValue(event?.killer_name), normalizeKillFeedDedupeValue(event?.victim_name), normalizeKillFeedDedupeValue(event?.killer_team), normalizeKillFeedDedupeValue(event?.victim_team), normalizeKillFeedDedupeValue(event?.weapon), event?.is_teamkill ? "1" : "0", ].join(":"), ) .join("|")}`; } function getKillFeedContextSignature(data) { return [ data?.server_slug, data?.match_id, data?.map, data?.map_id, data?.layer_id, data?.map_pretty_name, ] .map((value) => normalizeKillFeedDedupeValue(value)) .filter(Boolean) .join("|"); } function normalizeKillFeedDedupeValue(value) { return String(value ?? "").trim().toLowerCase(); } function isKnownKillFeedTeam(value) { return getPlayerTeamDisplay(value).key !== "unknown"; } function isAvailableKillFeedName(value, fallback) { const normalized = normalizeLookupText(value); return Boolean( normalized && normalized !== normalizeLookupText(fallback) && normalized !== normalizeLookupText("No disponible"), ); } function getKillFeedVisibleLimit() { if (window.matchMedia("(max-width: 480px)").matches) { return CURRENT_MATCH_KILL_FEED_VISIBLE_LIMIT_MOBILE; } if (window.matchMedia("(max-width: 1280px)").matches) { return CURRENT_MATCH_KILL_FEED_VISIBLE_LIMIT_MEDIUM; } return CURRENT_MATCH_KILL_FEED_VISIBLE_LIMIT_DESKTOP; } function compareKillFeedEvents(left, right) { const leftTime = Number(left.server_time); const rightTime = Number(right.server_time); if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { return leftTime - rightTime; } return ( String(left.event_timestamp || "").localeCompare(String(right.event_timestamp || "")) || String(left.event_id || "").localeCompare(String(right.event_id || "")) ); } function renderKillFeedColumns(events) { const splitIndex = Math.ceil(events.length / 2); return [events.slice(0, splitIndex), events.slice(splitIndex)] .map( (columnEvents) => `| Jugador | Equipo | Bajas | Muertes | TK | Muertes TK | Arma frecuente |
|---|