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...
`,
);
}
nodes.feedState = document.getElementById("current-match-feed-state");
nodes.feedList = document.getElementById("current-match-feed-list");
return {
byId: new Map(),
latestEventId: "",
contextSignature: "",
visibleSignature: "",
visibleCount: 0,
};
}
function initializePlayerStats(nodes) {
const shell = nodes.playersTitle?.closest(".panel__shell");
if (shell) {
shell.insertAdjacentHTML(
"beforeend",
`
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 = "";
nodes.feedState.hidden = true;
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) => `
${columnEvents.map(renderKillFeedRow).join("")}
`,
)
.join("");
}
function renderKillFeedRow(event) {
const weapon = resolveKillFeedWeapon(event.weapon);
const killerTeam = getKillFeedTeamDisplay(event.killer_team);
const victimTeam = getKillFeedTeamDisplay(event.victim_team);
const teamkillBadge = event.is_teamkill
? 'TK'
: "";
return `
${escapeHtml(event.killer_name || "Jugador no disponible")}
${renderKillFeedTeamBadge(killerTeam)}
${teamkillBadge}
${renderKillFeedWeaponIcon(weapon)}
${escapeHtml(weapon.label)}
${escapeHtml(event.victim_name || "Objetivo no disponible")}
${renderKillFeedTeamBadge(victimTeam)}
`;
}
function getKillFeedTeamDisplay(value) {
const team = getPlayerTeamDisplay(value);
return team.key === "unknown" ? { key: "unknown", label: "N/D" } : team;
}
function renderKillFeedTeamBadge(team) {
if (!team || team.key === "unknown") {
return "";
}
return `
${escapeHtml(team.label)}
`;
}
function resolveKillFeedWeapon(value) {
const runtimeWeaponIcons = globalThis.HLL_VIETNAM_CURRENT_MATCH_WEAPON_ICONS;
if (runtimeWeaponIcons?.lookup) {
const runtimeKey = runtimeWeaponIcons.normalize(value);
return runtimeWeaponIcons.lookup[runtimeKey] || {
label: String(value || runtimeWeaponIcons.unknown.label),
icon: runtimeWeaponIcons.unknown.icon,
};
}
return {
label: String(value || "Arma desconocida"),
icon: "",
};
}
function renderKillFeedWeaponIcon(weapon) {
if (!weapon.icon) {
return `
?
`;
}
return `
?
`;
}
function renderPlayerStats(data, nodes, state) {
const items = Array.isArray(data.items)
? sortPlayerStats(dedupeCurrentMatchPlayerStats(data.items))
: [];
renderDetectedPlayerCount(items.length, nodes);
if (!nodes.playerStatsShell || !nodes.playerStatsState) {
return;
}
if (items.length === 0) {
state.visibleSignature = "";
nodes.playerStatsShell.innerHTML = "";
nodes.playerStatsShell.hidden = true;
setState(
nodes.playerStatsState,
"Todavía no hay estadísticas fiables de jugadores para esta partida.",
);
return;
}
const signature = items
.map((item) =>
[
item.player_name,
item.team,
item.kills,
item.deaths,
item.teamkills,
item.deaths_by_teamkill,
item.favorite_weapon,
item.last_seen_at,
].join(":"),
)
.join("|");
if (signature !== state.visibleSignature) {
nodes.playerStatsShell.innerHTML = renderPlayerStatsTable(items);
state.visibleSignature = signature;
}
nodes.playerStatsShell.hidden = false;
nodes.playerStatsState.textContent = "";
nodes.playerStatsState.hidden = true;
nodes.playerStatsState.classList.remove("historical-state--error");
}
function renderDetectedPlayerCount(count, nodes) {
if (nodes.playerCount) {
nodes.playerCount.textContent = `Jugadores con estadisticas recientes: ${count}`;
}
}
function dedupeCurrentMatchPlayerStats(items) {
const byKey = new Map();
items.forEach((item) => {
const key = resolvePlayerStatsDedupeKey(byKey, item);
if (!key) {
return;
}
const current = byKey.get(key);
byKey.set(key, current ? mergePlayerStatsRows(current, item) : { ...item });
});
return [...byKey.values()];
}
function resolvePlayerStatsDedupeKey(byKey, item) {
const identityKey = getPlayerStatsIdentityKey(item);
const nameKey = getPlayerStatsNameKey(item);
if (identityKey) {
if (nameKey && byKey.has(nameKey)) {
const namedRow = byKey.get(nameKey);
byKey.delete(nameKey);
byKey.set(identityKey, byKey.has(identityKey)
? mergePlayerStatsRows(byKey.get(identityKey), namedRow)
: namedRow);
}
return identityKey;
}
if (!nameKey) {
return "";
}
const matchingIdentityKeys = [...byKey.entries()]
.filter(([key, row]) => key.startsWith("id:") && getPlayerStatsNameKey(row) === nameKey)
.map(([key]) => key);
return matchingIdentityKeys.length === 1 ? matchingIdentityKeys[0] : nameKey;
}
function getPlayerStatsIdentityKey(item) {
const playerId = normalizePlayerStatsDedupeValue(item?.player_id);
if (playerId) {
return `id:player:${playerId}`;
}
const steamId = normalizePlayerStatsDedupeValue(item?.steam_id_64);
if (steamId) {
return `id:steam:${steamId}`;
}
const epicId = normalizePlayerStatsDedupeValue(item?.epic_id);
if (epicId) {
return `id:epic:${epicId}`;
}
return "";
}
function getPlayerStatsNameKey(item) {
const name = normalizePlayerStatsDedupeValue(item?.player_name);
return name ? `name:${name}` : "";
}
function normalizePlayerStatsDedupeValue(value) {
return String(value ?? "").trim().toLowerCase().replace(/\s+/g, " ");
}
function mergePlayerStatsRows(current, candidate) {
if (areEquivalentPlayerStatsRows(current, candidate)) {
return pickRicherPlayerStatsRow(current, candidate);
}
const merged = {
...pickRicherPlayerStatsRow(current, candidate),
player_id: current.player_id || candidate.player_id,
steam_id_64: current.steam_id_64 || candidate.steam_id_64,
epic_id: current.epic_id || candidate.epic_id,
player_name: pickPlayerStatsName(current.player_name, candidate.player_name),
team: pickPlayerStatsTeam(current, candidate),
kills: toStatNumber(current.kills) + toStatNumber(candidate.kills),
deaths: toStatNumber(current.deaths) + toStatNumber(candidate.deaths),
teamkills: toStatNumber(current.teamkills) + toStatNumber(candidate.teamkills),
deaths_by_teamkill:
toStatNumber(current.deaths_by_teamkill) + toStatNumber(candidate.deaths_by_teamkill),
favorite_weapon: pickPlayerStatsFavoriteWeapon(current, candidate),
last_seen_at: pickLatestPlayerStatsValue(current.last_seen_at, candidate.last_seen_at),
source: mergePlayerStatsSources(current.source, candidate.source),
};
if (candidate.connected !== undefined || current.connected !== undefined) {
merged.connected = pickLatestPlayerStatsConnected(current, candidate);
merged.is_connected = merged.connected;
}
return merged;
}
function areEquivalentPlayerStatsRows(current, candidate) {
return [
"team",
"kills",
"deaths",
"teamkills",
"deaths_by_teamkill",
"favorite_weapon",
"last_seen_at",
].every((field) =>
normalizePlayerStatsDedupeValue(current?.[field]) ===
normalizePlayerStatsDedupeValue(candidate?.[field]),
);
}
function pickRicherPlayerStatsRow(current, candidate) {
return getPlayerStatsMetadataScore(candidate) > getPlayerStatsMetadataScore(current)
? { ...candidate }
: { ...current };
}
function getPlayerStatsMetadataScore(item) {
return [
Boolean(normalizePlayerStatsDedupeValue(item?.player_id)),
Boolean(normalizePlayerStatsDedupeValue(item?.steam_id_64)),
Boolean(normalizePlayerStatsDedupeValue(item?.epic_id)),
getPlayerTeamDisplay(item?.team).key !== "unknown",
Boolean(getAvailableFavoriteWeapon(item?.favorite_weapon)),
Boolean(normalizePlayerStatsDedupeValue(item?.last_seen_at)),
].filter(Boolean).length;
}
function pickPlayerStatsName(currentName, candidateName) {
const current = String(currentName || "").trim();
const candidate = String(candidateName || "").trim();
if (!current) {
return candidate;
}
if (!candidate) {
return current;
}
return candidate.length > current.length ? candidate : current;
}
function pickPlayerStatsTeam(current, candidate) {
const currentTeam = getPlayerTeamDisplay(current?.team);
const candidateTeam = getPlayerTeamDisplay(candidate?.team);
if (candidateTeam.key !== "unknown" && currentTeam.key === "unknown") {
return candidate.team;
}
if (candidateTeam.key !== "unknown" && isPlayerStatsCandidateNewer(current, candidate)) {
return candidate.team;
}
return current.team || candidate.team;
}
function pickPlayerStatsFavoriteWeapon(current, candidate) {
const currentWeapon = getAvailableFavoriteWeapon(current?.favorite_weapon);
const candidateWeapon = getAvailableFavoriteWeapon(candidate?.favorite_weapon);
if (!currentWeapon) {
return candidateWeapon || current.favorite_weapon || candidate.favorite_weapon;
}
if (!candidateWeapon) {
return currentWeapon;
}
return getPlayerStatsTotal(candidate) > getPlayerStatsTotal(current)
? candidateWeapon
: currentWeapon;
}
function getAvailableFavoriteWeapon(value) {
const normalized = normalizePlayerStatsDedupeValue(value);
return normalized && normalized !== "no disponible" ? String(value || "").trim() : "";
}
function pickLatestPlayerStatsValue(current, candidate) {
return String(candidate || "") > String(current || "") ? candidate : current;
}
function pickLatestPlayerStatsConnected(current, candidate) {
if (isPlayerStatsCandidateNewer(current, candidate)) {
return candidate.connected ?? candidate.is_connected ?? current.connected ?? current.is_connected;
}
return current.connected ?? current.is_connected ?? candidate.connected ?? candidate.is_connected;
}
function isPlayerStatsCandidateNewer(current, candidate) {
return String(candidate?.last_seen_at || "") > String(current?.last_seen_at || "");
}
function getPlayerStatsTotal(item) {
return (
toStatNumber(item?.kills) +
toStatNumber(item?.deaths) +
toStatNumber(item?.teamkills) +
toStatNumber(item?.deaths_by_teamkill)
);
}
function mergePlayerStatsSources(current, candidate) {
const sources = new Set(
[current, candidate]
.flatMap((value) => String(value || "").split(","))
.map((value) => value.trim())
.filter(Boolean),
);
return [...sources].sort().join(",");
}
function sortPlayerStats(items) {
return [...items].sort(
(left, right) =>
toStatNumber(right.kills) - toStatNumber(left.kills) ||
toStatNumber(left.deaths) - toStatNumber(right.deaths) ||
String(left.player_name || "").localeCompare(String(right.player_name || ""), "es", {
sensitivity: "base",
}),
);
}
function renderPlayerStatsTable(items) {
return `
| Jugador |
Equipo |
Bajas |
Muertes |
TK |
Muertes TK |
Arma frecuente |
${items.map(renderPlayerStatsRow).join("")}
`;
}
function renderPlayerStatsRow(item) {
const team = getPlayerTeamDisplay(item.team);
return `
| ${escapeHtml(item.player_name || "Jugador no disponible")} |
${escapeHtml(team.label)}
|
${escapeHtml(formatStatNumber(item.kills))} |
${escapeHtml(formatStatNumber(item.deaths))} |
${escapeHtml(formatStatNumber(item.teamkills))} |
${escapeHtml(formatStatNumber(item.deaths_by_teamkill))} |
${escapeHtml(item.favorite_weapon || "No disponible")} |
`;
}
function getPlayerTeamDisplay(value) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "allies" || normalized === "allied" || normalized === "aliados") {
return { key: "allies", label: "Aliados" };
}
if (normalized === "axis" || normalized === "eje") {
return { key: "axis", label: "Eje" };
}
return { key: "unknown", label: "No disponible" };
}
function toStatNumber(value) {
return Number.isFinite(Number(value)) ? Number(value) : 0;
}
function formatStatNumber(value) {
return Number.isFinite(Number(value)) ? String(Number(value)) : "0";
}
function renderCompactMeta(label, value) {
return `
${escapeHtml(label)}
${escapeHtml(value)}
`;
}
function formatStatus(value) {
if (value === "online") {
return "Online";
}
if (value === "offline") {
return "Offline";
}
return "No disponible";
}
function formatPlayers(players, maxPlayers) {
if (!isNumericValue(players) || !isNumericValue(maxPlayers)) {
return "No disponible";
}
return `${Number(players)} / ${Number(maxPlayers)}`;
}
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 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 `
${renderLiveSide("historical-scoreboard-side--allied", "Aliados", "./assets/img/factions/us.webp")}
${escapeHtml(formatStatus(data.status))}
${escapeHtml(scoreMarkup)}
${escapeHtml(mapName)}
${escapeHtml(formatGameMode(data.game_mode))}
${renderLiveSide("historical-scoreboard-side--axis", "Eje", "./assets/img/factions/germany.webp")}
${metadata.map(([label, value]) => renderCompactMeta(label, value)).join("")}
`;
}
function renderLiveSide(sideClass, label, emblem) {
return `
${escapeHtml(label)}
`;
}
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 resolver = globalThis.HLL_VIETNAM_MAP_IMAGES?.resolveMapImageAsset;
if (typeof resolver !== "function") {
return "";
}
return (
resolver({
candidates: [
data.layer_id,
data.map_id,
data.map,
data.map_pretty_name,
mapName,
],
})?.src || ""
);
}
function resolveTrustedScoreboardUrl(data) {
const trustedUrl = CURRENT_MATCH_SCOREBOARDS[data.server_slug];
return data.public_scoreboard_url === trustedUrl ? trustedUrl : "";
}
function formatServerDisplayName(data, fallbackName) {
const trustedName = CURRENT_MATCH_SERVERS[data.server_slug];
if (trustedName) {
return trustedName;
}
const normalized = String(fallbackName || "").trim();
const serverNumber = normalized.match(/^#0?([1-9])\b/);
if (serverNumber) {
return `Comunidad Hispana #${serverNumber[1].padStart(2, "0")}`;
}
return normalized || "Servidor no disponible";
}
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, visibleCount = 0, totalCount = 0) {
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.";
}
if (scope === "no-current-match-events") {
return "Todavía no se han detectado bajas en esta partida.";
}
return "Todavía 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) {
if (!node) {
return;
}
node.textContent = message;
node.hidden = false;
node.classList.toggle("historical-state--error", isError);
}
function setOptionalHref(node, href) {
if (node) {
node.href = href;
}
}
function setOptionalHidden(node, hidden) {
if (node) {
node.hidden = hidden;
}
}
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("'", "'");
}