This commit is contained in:
devRaGonSa
2026-06-05 16:57:25 +02:00
commit 0da8338ba8
310 changed files with 45849 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
(function () {
"use strict";
const DEFAULT_DEV_BACKEND = "http://127.0.0.1:8000";
function isLocalHost(hostname) {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
function hasOwn(object, property) {
return Object.prototype.hasOwnProperty.call(object || {}, property);
}
function resolveConfiguredBackendBaseUrl() {
const explicitConfig = window.HLL_FRONTEND_CONFIG || {};
if (hasOwn(explicitConfig, "backendBaseUrl")) {
return String(explicitConfig.backendBaseUrl || "");
}
const body = document.body;
if (body && body.dataset && hasOwn(body.dataset, "backendBaseUrl")) {
const bodyValue = body.dataset.backendBaseUrl;
if (bodyValue === DEFAULT_DEV_BACKEND && !isLocalHost(window.location.hostname)) {
return "";
}
return String(bodyValue || "");
}
return isLocalHost(window.location.hostname) ? DEFAULT_DEV_BACKEND : "";
}
function rewriteUrl(input) {
const configuredBaseUrl = resolveConfiguredBackendBaseUrl();
if (typeof input !== "string") {
return input;
}
if (configuredBaseUrl === "") {
if (input.startsWith(`${DEFAULT_DEV_BACKEND}/`)) {
return input.slice(DEFAULT_DEV_BACKEND.length);
}
return input;
}
if (input.startsWith(`${DEFAULT_DEV_BACKEND}/`)) {
return `${configuredBaseUrl}${input.slice(DEFAULT_DEV_BACKEND.length)}`;
}
return input;
}
const nativeFetch = window.fetch.bind(window);
window.fetch = function hllConfiguredFetch(input, init) {
if (typeof input === "string") {
return nativeFetch(rewriteUrl(input), init);
}
if (input instanceof Request) {
const rewrittenUrl = rewriteUrl(input.url);
if (rewrittenUrl !== input.url) {
return nativeFetch(new Request(rewrittenUrl, input), init);
}
}
return nativeFetch(input, init);
};
window.HLL_FRONTEND_CONFIG = Object.freeze({
...window.HLL_FRONTEND_CONFIG,
backendBaseUrl: resolveConfiguredBackendBaseUrl(),
});
})();

View File

@@ -0,0 +1,979 @@
document.addEventListener("DOMContentLoaded", () => {
const backendBaseUrl =
document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
const params = new URLSearchParams(window.location.search);
const serverSlug = params.get("server") || "";
const matchId = params.get("match") || "";
const nodes = {
title: document.getElementById("match-detail-title"),
summary: document.getElementById("match-detail-summary"),
note: document.getElementById("match-detail-note"),
state: document.getElementById("match-detail-state"),
grid: document.getElementById("match-detail-grid"),
actions: document.getElementById("match-detail-actions"),
playersSection: document.getElementById("match-detail-players-section"),
playersNote: document.getElementById("match-detail-players-note"),
playersState: document.getElementById("match-detail-players-state"),
playerControls: document.getElementById("match-detail-player-controls"),
playerSearch: document.getElementById("match-detail-player-search"),
playerTeamFilters: [...document.querySelectorAll('input[name="match-detail-player-team-filter"]')],
playerSort: document.getElementById("match-detail-player-sort"),
playerSortDirection: document.getElementById("match-detail-player-sort-direction"),
playersTableShell: document.getElementById("match-detail-players-table-shell"),
playersBody: document.getElementById("match-detail-players-body"),
timelineSection: document.getElementById("match-detail-timeline-section"),
timelineNote: document.getElementById("match-detail-timeline-note"),
timelineState: document.getElementById("match-detail-timeline-state"),
timelineGrid: document.getElementById("match-detail-timeline-grid"),
mapHero: document.getElementById("match-detail-map-hero"),
mapImage: document.getElementById("match-detail-map-image"),
};
if (!serverSlug || !matchId) {
nodes.title.textContent = "Partida no seleccionada";
nodes.summary.textContent = "Vuelve al historico y abre una partida registrada.";
nodes.note.textContent = "";
setState(nodes.state, "No hay una partida seleccionada.", true);
return;
}
void loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes });
});
async function loadMatchDetail({ backendBaseUrl, serverSlug, matchId, nodes }) {
try {
const payload = await fetchJson(
`${backendBaseUrl}/api/historical/matches/detail?server=${encodeURIComponent(
serverSlug,
)}&match=${encodeURIComponent(matchId)}`,
);
const data = payload?.data;
const item = data?.item;
if (!data?.found || !item) {
nodes.title.textContent = "Detalle no disponible";
nodes.summary.textContent =
"La partida existe como enlace interno, pero todavia no hay detalle suficiente para mostrar.";
nodes.note.textContent = "";
setState(nodes.state, "Detalle no disponible para esta partida.");
return;
}
renderMatchDetail(item, nodes);
} catch (error) {
nodes.title.textContent = "Detalle no disponible";
nodes.summary.textContent = "No se pudo conectar con el backend local.";
nodes.note.textContent = "";
setState(nodes.state, "Error al cargar el detalle de la partida.", true);
}
}
function renderMatchDetail(item, nodes) {
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
const serverName = item.server?.name || item.server?.slug || "Servidor no disponible";
nodes.title.textContent = mapName;
nodes.summary.textContent = serverName;
nodes.note.textContent = "";
renderMapHero(item, mapName, nodes);
nodes.grid.innerHTML = renderScoreboardDetail(item, { mapName, serverName });
renderPlayerSection(item, nodes);
hideTimelineSection(nodes);
renderActions(item, nodes.actions);
nodes.state.hidden = true;
nodes.grid.hidden = false;
}
function renderMapHero(item, mapName, nodes) {
if (!nodes.mapHero || !nodes.mapImage) {
return;
}
const mapImagePath = resolveMapImagePath(item, mapName);
if (!mapImagePath) {
nodes.mapImage.removeAttribute("src");
nodes.mapImage.alt = "";
nodes.mapHero.hidden = true;
return;
}
nodes.mapImage.src = mapImagePath;
nodes.mapImage.alt = mapName;
nodes.mapImage.onerror = () => {
nodes.mapImage.removeAttribute("src");
nodes.mapHero.hidden = true;
};
nodes.mapHero.hidden = false;
}
function renderScoreboardDetail(item, { mapName, serverName }) {
const result = item.result || {};
const alliedScore = Number.isFinite(Number(result.allied_score))
? formatNumber(result.allied_score)
: "-";
const axisScore = Number.isFinite(Number(result.axis_score))
? formatNumber(result.axis_score)
: "-";
const winner = String(item.winner || result.winner || "").toLowerCase();
const isAlliedWinner = winner === "allies" || winner === "allied";
const isAxisWinner = winner === "axis";
const factions = resolveMatchFactions(item, mapName);
const metadata = [
["Servidor", serverName],
["Mapa", mapName],
["Modo", formatGameMode(item.game_mode || item.gamestate?.game_mode)],
["Duracion", formatDuration(item.duration_seconds)],
["Inicio", formatMatchTimestamp(item, "start")],
];
if (item.ended_at) {
metadata.push(["Fin", formatMatchTimestamp(item, "end")]);
}
return `
<section class="historical-scoreboard-layout" aria-label="Resumen de marcador de la partida">
<div class="historical-scoreboard-layout__main">
${renderScoreboardSide({
sideClass: "historical-scoreboard-side--allied",
emblem: factions.allied.emblem,
sideLabel: "Aliados",
factionLabel: factions.allied.label,
isWinner: isAlliedWinner,
})}
<div class="historical-scoreboard-center">
<span class="historical-scoreboard-center__timer">${escapeHtml(formatDuration(item.duration_seconds))}</span>
<strong class="historical-scoreboard-center__score">${escapeHtml(alliedScore)} : ${escapeHtml(axisScore)}</strong>
<span class="historical-scoreboard-center__map">${escapeHtml(mapName)}</span>
<span class="historical-scoreboard-center__mode">${escapeHtml(formatGameMode(item.game_mode || item.gamestate?.game_mode))}</span>
<span class="historical-scoreboard-center__winner">${escapeHtml(formatWinner(winner))}</span>
</div>
${renderScoreboardSide({
sideClass: "historical-scoreboard-side--axis",
emblem: factions.axis.emblem,
sideLabel: "Eje",
factionLabel: factions.axis.label,
isWinner: isAxisWinner,
})}
</div>
<div class="historical-scoreboard-layout__meta">
${metadata.map(([label, value]) => renderCompactMeta(label, value)).join("")}
</div>
</section>
`;
}
function renderScoreboardSide({ sideClass, emblem, sideLabel, factionLabel, isWinner }) {
const fallbackLabel = factionLabel || sideLabel;
return `
<div class="historical-scoreboard-side ${sideClass} ${isWinner ? "is-winner" : ""}">
<img
class="historical-scoreboard-side__emblem"
src="${escapeHtml(emblem)}"
alt="${escapeHtml(fallbackLabel)}"
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(sideLabel)}</strong>
${isWinner ? "<em>Ganador</em>" : ""}
</div>
</div>
`;
}
function renderCompactMeta(label, value) {
return `
<article>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value || "No disponible")}</strong>
</article>
`;
}
function hideTimelineSection(nodes) {
if (!nodes.timelineSection) {
return;
}
nodes.timelineSection.hidden = true;
nodes.timelineNote.textContent = "";
nodes.timelineState.hidden = true;
nodes.timelineGrid.hidden = true;
nodes.timelineGrid.innerHTML = "";
}
function renderPlayerSection(item, nodes) {
const players = Array.isArray(item.players) ? item.players : [];
nodes.playersSection.hidden = false;
if (players.length === 0) {
nodes.playersNote.textContent =
"Esta partida no tiene estadisticas por jugador disponibles en el detalle interno.";
setState(
nodes.playersState,
"No hay filas de jugador registradas para esta partida.",
);
nodes.playerControls.hidden = true;
nodes.playersTableShell.hidden = true;
nodes.playersBody.innerHTML = "";
return;
}
const state = {
search: "",
team: "all",
sort: "kills",
direction: "desc",
isDefaultSort: true,
};
const renderRows = () => renderPlayerTable(item, players, state, nodes);
nodes.playerSearch.value = "";
nodes.playerTeamFilters.forEach((control) => {
control.checked = control.value === state.team;
});
nodes.playerSort.value = state.sort;
nodes.playerSortDirection.value = state.direction;
bindPlayerTableControls(nodes, state, renderRows);
renderRows();
nodes.playerControls.hidden = false;
nodes.playersTableShell.hidden = false;
}
function bindPlayerTableControls(nodes, state, renderRows) {
nodes.playerControls.onsubmit = (event) => {
event.preventDefault();
};
nodes.playerSearch.oninput = () => {
closePlayerDetailRows(nodes.playersBody);
state.search = nodes.playerSearch.value;
renderRows();
};
nodes.playerTeamFilters.forEach((control) => {
control.onchange = () => {
closePlayerDetailRows(nodes.playersBody);
state.team = control.value;
renderRows();
};
});
nodes.playerSort.onchange = () => {
state.sort = nodes.playerSort.value;
state.isDefaultSort = false;
renderRows();
};
nodes.playerSortDirection.onchange = () => {
state.direction = nodes.playerSortDirection.value;
state.isDefaultSort = false;
renderRows();
};
}
function renderPlayerTable(item, players, state, nodes) {
const visiblePlayers = getVisiblePlayers(players, item, state);
nodes.playersNote.textContent =
visiblePlayers.length === players.length
? `${formatNumber(players.length)} jugadores con estadisticas locales.`
: `${formatNumber(visiblePlayers.length)} de ${formatNumber(players.length)} jugadores visibles.`;
nodes.playersState.hidden = visiblePlayers.length > 0;
if (!visiblePlayers.length) {
nodes.playersState.textContent = "No hay jugadores que coincidan con los controles activos.";
}
nodes.playersBody.innerHTML = visiblePlayers
.map((entry, index) => renderPlayerRows(entry.player, item, index, entry.inactive))
.join("");
bindPlayerDetailRows(nodes.playersBody);
}
function getVisiblePlayers(players, item, state) {
const normalizedSearch = normalizeLookupText(state.search);
return players
.map((player) => ({
player,
inactive: isInactiveMatchPlayer(player),
team: getTeamSideDisplay(player.team || player.team_side),
}))
.filter((entry) => {
const matchesTeam = state.team === "all" || entry.team.key === state.team;
const matchesName =
!normalizedSearch ||
normalizeLookupText(entry.player.player_name).includes(normalizedSearch);
return matchesTeam && matchesName;
})
.sort((a, b) => comparePlayerEntries(a, b, item, state));
}
function comparePlayerEntries(a, b, item, state) {
if (state.isDefaultSort) {
return (
compareInactivePriority(a, b) ||
compareNumericStat(b.player.kills, a.player.kills) ||
compareNumericStat(a.player.deaths, b.player.deaths) ||
comparePlayerNames(a.player, b.player)
);
}
if (!["name", "team"].includes(state.sort)) {
const inactivePriority = compareInactivePriority(a, b);
if (inactivePriority) {
return inactivePriority;
}
}
const direction = state.direction === "asc" ? 1 : -1;
const compared = comparePlayerSortValue(a, b, item, state.sort);
return compared * direction || comparePlayerNames(a.player, b.player);
}
function comparePlayerSortValue(a, b, item, sort) {
if (sort === "name") {
return comparePlayerNames(a.player, b.player);
}
if (sort === "team") {
return compareText(a.team.label, b.team.label);
}
if (sort === "deaths" || sort === "teamkills" || sort === "kills") {
return compareNumericStat(a.player[sort], b.player[sort]);
}
if (sort === "kd") {
return compareNumericStat(getKdRatioValue(a.player), getKdRatioValue(b.player));
}
return compareNumericStat(
getKpmValue(a.player.kills, item.duration_seconds),
getKpmValue(b.player.kills, item.duration_seconds),
);
}
function compareInactivePriority(a, b) {
return Number(a.inactive) - Number(b.inactive);
}
function comparePlayerNames(a, b) {
return compareText(getPlayerName(a), getPlayerName(b));
}
function compareText(a, b) {
return String(a || "").localeCompare(String(b || ""), "es", {
sensitivity: "base",
});
}
function compareNumericStat(a, b) {
return toSortableNumber(a) - toSortableNumber(b);
}
function renderPlayerRows(player, item, index, inactive = false) {
const team = getTeamSideDisplay(player.team || player.team_side);
const rowId = `match-player-row-${index}`;
const panelId = `match-player-panel-${index}`;
const playerName = getPlayerName(player);
const kpm = formatKpm(player.kills, item.duration_seconds);
return `
<tr
class="historical-player-row historical-player-row--${team.key} ${inactive ? "is-inactive" : ""}"
id="${escapeHtml(rowId)}"
>
<td>
<button
class="historical-player-row__details-button"
type="button"
aria-controls="${escapeHtml(panelId)}"
aria-expanded="false"
aria-label="Ver estadisticas ampliadas de ${escapeHtml(playerName)}"
>
<span>${escapeHtml(playerName)}</span>
<span aria-hidden="true">i</span>
</button>
</td>
<td class="historical-player-team-cell">
<span class="historical-player-team-badge historical-player-team-badge--${team.key}">
${escapeHtml(team.label)}
</span>
</td>
<td>${escapeHtml(formatOptionalNumber(player.kills))}</td>
<td>${escapeHtml(formatOptionalNumber(player.deaths))}</td>
<td>${escapeHtml(formatOptionalNumber(player.teamkills))}</td>
<td>${escapeHtml(formatKdRatio(player))}</td>
<td>${escapeHtml(kpm)}</td>
</tr>
<tr
class="historical-player-detail-row"
id="${escapeHtml(panelId)}"
aria-labelledby="${escapeHtml(rowId)}"
>
<td colspan="7">
${renderPlayerStatsPanel(player, item, { team, playerName, kpm })}
</td>
</tr>
`;
}
function bindPlayerDetailRows(playersBody) {
const playerRows = [...playersBody.querySelectorAll(".historical-player-row")];
const collapseRow = (row) => {
const button = row.querySelector(".historical-player-row__details-button");
const detailRow = row.nextElementSibling;
if (!button || !detailRow?.classList.contains("historical-player-detail-row")) {
return;
}
row.classList.remove("is-expanded");
detailRow.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
};
playerRows.forEach((row) => {
const button = row.querySelector(".historical-player-row__details-button");
const detailRow = row.nextElementSibling;
if (!button || !detailRow?.classList.contains("historical-player-detail-row")) {
return;
}
const setExpanded = (expanded) => {
if (expanded) {
playerRows.filter((candidate) => candidate !== row).forEach(collapseRow);
}
row.classList.toggle("is-expanded", expanded);
detailRow.classList.toggle("is-open", expanded);
button.setAttribute("aria-expanded", String(expanded));
};
const toggleExpanded = () => setExpanded(!detailRow.classList.contains("is-open"));
button.addEventListener("click", () => {
toggleExpanded();
});
});
}
function closePlayerDetailRows(playersBody) {
[...playersBody.querySelectorAll(".historical-player-row")].forEach((row) => {
const button = row.querySelector(".historical-player-row__details-button");
const detailRow = row.nextElementSibling;
if (!button || !detailRow?.classList.contains("historical-player-detail-row")) {
return;
}
row.classList.remove("is-expanded");
detailRow.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
});
}
function getPlayerName(player) {
return player.player_name || player.name || "Jugador no identificado";
}
function isInactiveMatchPlayer(player) {
const team = getTeamSideDisplay(player.team || player.team_side);
return (
team.key === "unknown" &&
toSortableNumber(player.kills) === 0 &&
toSortableNumber(player.deaths) === 0 &&
toSortableNumber(player.teamkills) === 0 &&
getKdRatioValue(player) === 0 &&
!hasNamedCounts(player.top_weapons) &&
!hasNamedCounts(player.most_killed) &&
!hasNamedCounts(player.death_by)
);
}
function renderPlayerStatsPanel(player, item, context) {
const matchups = buildPlayerDirectMatchups(player);
const hasExpandedStats =
hasNamedCounts(player.top_weapons) ||
hasNamedCounts(player.most_killed) ||
hasNamedCounts(player.death_by) ||
matchups.length > 0;
return `
<section class="historical-player-stats-panel" aria-label="Estadisticas ampliadas de ${escapeHtml(context.playerName)}">
<div class="historical-player-stats-panel__header">
<div>
<p>${escapeHtml(context.team.label)}</p>
<h4>${escapeHtml(context.playerName)}</h4>
</div>
<div class="historical-player-stats-panel__summary">
${renderPlayerStatChip("Kills", formatOptionalNumber(player.kills))}
${renderPlayerStatChip("Muertes", formatOptionalNumber(player.deaths))}
${renderPlayerStatChip("TK", formatOptionalNumber(player.teamkills))}
${renderPlayerStatChip("KD", formatKdRatio(player))}
${renderPlayerStatChip("KPM", context.kpm)}
</div>
</div>
${renderExternalProfilesSection(player)}
${
hasExpandedStats
? `
<div class="historical-player-stats-panel__grid">
${renderNamedCountSection("Armas", player.top_weapons)}
${renderNamedCountSection("Mas abatido", player.most_killed)}
${renderNamedCountSection("Muere por", player.death_by)}
${renderDirectMatchupsSection(matchups)}
</div>
`
: `<p class="historical-player-stats-panel__empty">Sin estadisticas ampliadas disponibles.</p>`
}
</section>
`;
}
function renderExternalProfilesSection(player) {
const links = [
["steam", "Steam"],
["hellor", "Hellor"],
["hll_records", "HLL Records"],
["helo", "Helo"],
]
.map(([key, label]) => [label, player.external_profile_links?.[key]])
.filter(([, href]) => typeof href === "string" && href.trim());
return `
<article class="historical-player-stats-panel__section historical-player-stats-panel__profiles">
<h5>Perfiles externos</h5>
${
links.length
? `
<div class="historical-player-profile-links">
${links
.map(
([label, href]) => `
<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">
${escapeHtml(label)}
</a>
`,
)
.join("")}
</div>
`
: renderExternalProfilesUnavailable(player)
}
</article>
`;
}
function renderExternalProfilesUnavailable(player) {
const platform = String(player.platform || "").toLowerCase();
const epicId = typeof player.epic_id === "string" ? player.epic_id.trim() : "";
if (platform === "epic") {
return epicId
? `<p>Jugador detectado como Epic. ID capturado: <code>${escapeHtml(epicId)}</code>. Sin enlaces externos compatibles confirmados para este proveedor.</p>`
: "<p>Jugador detectado como Epic. Sin enlaces externos compatibles confirmados para este proveedor.</p>";
}
return "<p>Perfiles externos no disponibles.</p>";
}
function renderPlayerStatChip(label, value) {
return `
<article>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</article>
`;
}
function renderNamedCountSection(title, items) {
if (!hasNamedCounts(items)) {
return `
<article class="historical-player-stats-panel__section">
<h5>${escapeHtml(title)}</h5>
<p>No disponible</p>
</article>
`;
}
return `
<article class="historical-player-stats-panel__section">
<h5>${escapeHtml(title)}</h5>
<ol>
${items
.map((stat) => {
const name = stat.name || stat.label || "Sin nombre";
const count = stat.count ?? stat.total ?? 0;
return `<li><span>${escapeHtml(name)}</span><strong>${escapeHtml(formatNumber(count))}</strong></li>`;
})
.join("")}
</ol>
</article>
`;
}
function renderDirectMatchupsSection(matchups) {
if (!matchups.length) {
return `
<article class="historical-player-stats-panel__section historical-player-stats-panel__section--wide">
<h5>Duelo directo</h5>
<p>No disponible</p>
</article>
`;
}
return `
<article class="historical-player-stats-panel__section historical-player-stats-panel__section--wide">
<h5>Duelo directo</h5>
<div class="historical-player-matchups" role="table" aria-label="Duelos directos">
<div role="row">
<span role="columnheader">Rival</span>
<span role="columnheader">Abatidos</span>
<span role="columnheader">Muertes</span>
<span role="columnheader">Balance</span>
</div>
${matchups
.map(
(matchup) => `
<div role="row">
<span role="cell">${escapeHtml(matchup.name)}</span>
<strong role="cell">${escapeHtml(formatNumber(matchup.kills))}</strong>
<strong role="cell">${escapeHtml(formatNumber(matchup.deaths))}</strong>
<strong role="cell">${escapeHtml(formatSignedNumber(matchup.balance))}</strong>
</div>
`,
)
.join("")}
</div>
</article>
`;
}
function buildPlayerDirectMatchups(player) {
const byName = new Map();
const addStats = (items, key) => {
if (!Array.isArray(items)) {
return;
}
items.forEach((item) => {
const name = item.name || item.label;
if (!name) {
return;
}
const normalizedName = String(name);
const current = byName.get(normalizedName) || {
name: normalizedName,
kills: 0,
deaths: 0,
};
current[key] += Number(item.count ?? item.total ?? 0) || 0;
byName.set(normalizedName, current);
});
};
addStats(player.most_killed, "kills");
addStats(player.death_by, "deaths");
return [...byName.values()]
.map((matchup) => ({
...matchup,
balance: matchup.kills - matchup.deaths,
involvement: matchup.kills + matchup.deaths,
}))
.sort((a, b) => b.involvement - a.involvement || a.name.localeCompare(b.name, "es"))
.slice(0, 8);
}
function renderActions(item, actionsNode) {
const matchUrl = normalizeSafePublicScoreboardMatchUrl(item.match_url);
if (!matchUrl) {
actionsNode.innerHTML = "";
actionsNode.hidden = true;
return;
}
actionsNode.innerHTML = `
<a
class="historical-match-card__link"
data-match-detail-scoreboard-link
href="${escapeHtml(matchUrl)}"
target="_blank"
rel="noopener noreferrer"
>
Ver en Scoreboard
</a>
`;
actionsNode.hidden = false;
}
function resolveMatchFactions(item, mapName) {
const normalizedMap = normalizeLookupText(
`${item.map?.name || ""} ${item.map?.pretty_name || ""} ${mapName || ""}`,
);
if (/(kursk|stalingrad|kharkov)/.test(normalizedMap)) {
return {
allied: {
label: "Sovieticos",
emblem: "./assets/img/factions/soviets.webp",
},
axis: {
label: "Eje",
emblem: "./assets/img/factions/germany.webp",
},
};
}
if (/(driel|elalamein|el alamein|tobruk)/.test(normalizedMap)) {
return {
allied: {
label: "Britanicos",
emblem: "./assets/img/factions/britain.webp",
},
axis: {
label: normalizedMap.includes("tobruk") || normalizedMap.includes("elalamein")
? "Afrika Korps"
: "Eje",
emblem: "./assets/img/factions/germany.webp",
},
};
}
return {
allied: {
label: "USA",
emblem: "./assets/img/factions/us.webp",
},
axis: {
label: "Eje",
emblem: "./assets/img/factions/germany.webp",
},
};
}
function resolveMapImagePath(item, mapName) {
const normalizedMap = normalizeLookupText(
`${item.map?.name || ""} ${item.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 normalizeLookupText(value) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function formatTeamSide(value) {
return getTeamSideDisplay(value).label;
}
function getTeamSideDisplay(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 formatGameMode(value) {
if (!value) {
return "Modo 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 "Duracion no disponible";
}
const minutes = Math.round(seconds / 60);
if (minutes < 60) {
return `${formatNumber(minutes)} min`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${formatNumber(hours)} h ${formatNumber(remainingMinutes)} min`;
}
function formatWinner(value) {
const normalized = String(value || "").toLowerCase();
if (normalized === "allies" || normalized === "allied") {
return "Ganador: Aliados";
}
if (normalized === "axis") {
return "Ganador: Eje";
}
if (normalized === "draw") {
return "Empate";
}
return "Resultado no disponible";
}
function formatOptionalNumber(value) {
return value === null || value === undefined ? "No disponible" : formatNumber(value);
}
function formatKdRatio(player) {
if (
!Number.isFinite(Number(player.kd_ratio)) &&
(!Number.isFinite(Number(player.kills)) || !Number.isFinite(Number(player.deaths)))
) {
return "No disponible";
}
return formatDecimal(getKdRatioValue(player), 2);
}
function getKdRatioValue(player) {
if (Number.isFinite(Number(player.kd_ratio))) {
return Number(player.kd_ratio);
}
const kills = Number(player.kills);
const deaths = Number(player.deaths);
if (!Number.isFinite(kills) || !Number.isFinite(deaths)) {
return 0;
}
return deaths > 0 ? kills / deaths : kills;
}
function formatKpm(kills, durationSeconds) {
return formatDecimal(getKpmValue(kills, durationSeconds), 2);
}
function getKpmValue(kills, durationSeconds) {
const parsedKills = Number(kills);
const parsedDurationSeconds = Number(durationSeconds);
if (
!Number.isFinite(parsedKills) ||
!Number.isFinite(parsedDurationSeconds) ||
parsedDurationSeconds <= 0
) {
return 0;
}
return parsedKills / (parsedDurationSeconds / 60);
}
function formatNamedCounts(items) {
if (!Array.isArray(items) || items.length === 0) {
return "No disponible";
}
return items
.slice(0, 3)
.map((item) => {
const name = item.name || item.label || "Sin nombre";
const count = item.count ?? item.total ?? 0;
return `${name} (${formatNumber(count)})`;
})
.join(" / ");
}
function hasNamedCounts(items) {
return Array.isArray(items) && items.length > 0;
}
function formatNumber(value) {
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue)) {
return "0";
}
return new Intl.NumberFormat("es-ES").format(parsedValue);
}
function toSortableNumber(value) {
const parsedValue = Number(value);
return Number.isFinite(parsedValue) ? parsedValue : 0;
}
function formatSignedNumber(value) {
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue) || parsedValue === 0) {
return "0";
}
return `${parsedValue > 0 ? "+" : ""}${formatNumber(parsedValue)}`;
}
function formatDecimal(value, fractionDigits = 1) {
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue)) {
return "0";
}
return new Intl.NumberFormat("es-ES", {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(parsedValue);
}
function formatTimestamp(timestamp) {
if (!timestamp) {
return "Fecha no disponible";
}
const value = new Date(timestamp);
if (Number.isNaN(value.getTime())) {
return "Fecha no disponible";
}
return new Intl.DateTimeFormat("es-ES", {
dateStyle: "short",
timeStyle: "short",
}).format(value);
}
function formatMatchTimestamp(item, kind) {
const timestamp = kind === "start" ? item.started_at : item.ended_at;
if (timestamp) {
return formatTimestamp(timestamp);
}
return "No disponible";
}
function normalizeSafePublicScoreboardMatchUrl(value) {
if (typeof value !== "string" || !value.trim()) {
return "";
}
try {
const url = new URL(value.trim());
const allowedOrigins = new Set([
"https://scoreboard.comunidadhll.es",
"https://scoreboard.comunidadhll.es:5443",
]);
const isAllowedPath = url.pathname === "/games" || url.pathname.startsWith("/games/");
return allowedOrigins.has(url.origin) && isAllowedPath ? url.href : "";
} catch (error) {
return "";
}
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`);
}
return response.json();
}
function setState(node, message, isError = false) {
node.textContent = message;
node.hidden = false;
node.classList.toggle("is-error", isError);
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

View File

@@ -0,0 +1,363 @@
(() => {
const RECENT_MATCHES_ENDPOINT = "/api/historical/recent-matches";
const REFRESH_DELAYS_MS = [150, 1000, 3000, 6000];
const RECENT_MATCHES_LIMIT = 100;
const DEFAULT_RECENT_MATCHES_PAGE_SIZE = 10;
const RECENT_MATCHES_PAGE_SIZES = Object.freeze([10, 25, 50, 100]);
const LIVE_PAGINATION_ID = "recent-matches-live-pagination";
const LEGACY_PAGINATION_ID = "recent-matches-pagination";
const recentMatchesState = {
items: [],
serverSlug: "all-servers",
page: 1,
pageSize: DEFAULT_RECENT_MATCHES_PAGE_SIZE,
activeRequestId: 0,
rendering: false,
observerReady: false,
};
document.addEventListener("DOMContentLoaded", () => {
ensureDynamicPaginationControls();
setupRecentMatchesOwnershipObserver();
REFRESH_DELAYS_MS.forEach((delay) => {
window.setTimeout(() => {
void refreshDynamicRecentMatches();
}, delay);
});
document.querySelectorAll("[data-server-slug]").forEach((button) => {
button.addEventListener("click", () => {
REFRESH_DELAYS_MS.forEach((delay) => {
window.setTimeout(() => {
void refreshDynamicRecentMatches(button.dataset.serverSlug);
}, delay);
});
});
});
});
async function refreshDynamicRecentMatches(forcedServerSlug) {
const listNode = document.getElementById("recent-matches-list");
const stateNode = document.getElementById("recent-matches-state");
const metaNode = document.getElementById("recent-matches-snapshot-meta");
const noteNode = document.getElementById("recent-matches-note");
if (!listNode || !stateNode || !metaNode) return;
const backendBaseUrl = document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
const serverSlug = normalizeDynamicServerSlug(forcedServerSlug || readServerFromUrl());
const shouldResetPage = serverSlug !== recentMatchesState.serverSlug;
const requestId = recentMatchesState.activeRequestId + 1;
recentMatchesState.activeRequestId = requestId;
recentMatchesState.serverSlug = serverSlug;
try {
const response = await fetch(`${backendBaseUrl}${RECENT_MATCHES_ENDPOINT}?server=${encodeURIComponent(serverSlug)}&limit=${RECENT_MATCHES_LIMIT}`, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
if (requestId !== recentMatchesState.activeRequestId || serverSlug !== recentMatchesState.serverSlug) {
return;
}
const data = payload?.data || {};
const items = Array.isArray(data.items) ? data.items : [];
recentMatchesState.items = items;
if (shouldResetPage) {
recentMatchesState.page = 1;
}
if (!items.length) {
recentMatchesState.page = 1;
setDynamicState(stateNode, "No hay partidas recientes disponibles para este alcance.");
renderOwnedList(listNode, "");
metaNode.textContent = "Datos recientes sin partidas disponibles.";
renderDynamicPagination();
return;
}
stateNode.hidden = true;
if (noteNode) noteNode.textContent = "Lista dinámica de partidas registradas.";
metaNode.textContent = buildDynamicRecentMeta(items);
renderDynamicRecentMatchesPage();
} catch (error) {
if (requestId !== recentMatchesState.activeRequestId || serverSlug !== recentMatchesState.serverSlug) {
return;
}
recentMatchesState.items = [];
recentMatchesState.page = 1;
setDynamicState(stateNode, "No se pudieron cargar las partidas recientes dinámicas.", true);
metaNode.textContent = "Error al leer las partidas recientes dinámicas.";
renderDynamicPagination();
}
}
function renderDynamicRecentMatchesPage() {
const listNode = document.getElementById("recent-matches-list");
const stateNode = document.getElementById("recent-matches-state");
if (!listNode || !stateNode) return;
const totalItems = recentMatchesState.items.length;
const totalPages = getDynamicTotalPages();
recentMatchesState.page = clampDynamicPage(recentMatchesState.page, totalPages);
if (!totalItems) {
renderOwnedList(listNode, "");
setDynamicState(stateNode, "No hay partidas recientes disponibles para este alcance.");
renderDynamicPagination();
return;
}
const startIndex = (recentMatchesState.page - 1) * recentMatchesState.pageSize;
const pageItems = recentMatchesState.items.slice(startIndex, startIndex + recentMatchesState.pageSize);
renderOwnedList(listNode, pageItems.map((item) => renderDynamicRecentMatchCard(item)).join(""));
stateNode.hidden = true;
renderDynamicPagination();
}
function renderOwnedList(listNode, html) {
recentMatchesState.rendering = true;
listNode.innerHTML = html;
window.queueMicrotask(() => {
recentMatchesState.rendering = false;
});
}
function setupRecentMatchesOwnershipObserver() {
const listNode = document.getElementById("recent-matches-list");
if (!listNode || recentMatchesState.observerReady || typeof MutationObserver === "undefined") return;
recentMatchesState.observerReady = true;
const observer = new MutationObserver(() => {
if (recentMatchesState.rendering || !recentMatchesState.items.length) return;
window.setTimeout(() => {
if (!recentMatchesState.rendering && recentMatchesState.items.length) {
renderDynamicRecentMatchesPage();
}
}, 0);
});
observer.observe(listNode, { childList: true });
}
function ensureDynamicPaginationControls() {
const listNode = document.getElementById("recent-matches-list");
if (!listNode || document.getElementById(LIVE_PAGINATION_ID)) return;
const paginationNode = document.createElement("div");
paginationNode.className = "historical-pagination";
paginationNode.id = LIVE_PAGINATION_ID;
paginationNode.hidden = true;
const sizeLabel = document.createElement("label");
sizeLabel.className = "historical-pagination__size";
sizeLabel.append("Mostrar ");
const pageSizeSelect = document.createElement("select");
pageSizeSelect.id = "recent-matches-live-page-size";
pageSizeSelect.setAttribute("aria-label", "Partidas por pagina");
RECENT_MATCHES_PAGE_SIZES.forEach((size) => {
const option = document.createElement("option");
option.value = String(size);
option.textContent = String(size);
option.selected = size === DEFAULT_RECENT_MATCHES_PAGE_SIZE;
pageSizeSelect.append(option);
});
sizeLabel.append(pageSizeSelect);
const navNode = document.createElement("div");
navNode.className = "historical-pagination__nav";
navNode.setAttribute("aria-label", "Paginacion de partidas recientes");
const prevButton = document.createElement("button");
prevButton.className = "historical-tab";
prevButton.type = "button";
prevButton.id = "recent-matches-live-prev";
prevButton.textContent = "Anterior";
const pageLabel = document.createElement("p");
pageLabel.id = "recent-matches-live-page-label";
pageLabel.textContent = "Pagina 1 de 1";
const nextButton = document.createElement("button");
nextButton.className = "historical-tab";
nextButton.type = "button";
nextButton.id = "recent-matches-live-next";
nextButton.textContent = "Siguiente";
navNode.append(prevButton, pageLabel, nextButton);
paginationNode.append(sizeLabel, navNode);
listNode.insertAdjacentElement("afterend", paginationNode);
pageSizeSelect.addEventListener("change", () => {
const nextPageSize = Number(pageSizeSelect.value);
recentMatchesState.pageSize = RECENT_MATCHES_PAGE_SIZES.includes(nextPageSize) ? nextPageSize : DEFAULT_RECENT_MATCHES_PAGE_SIZE;
recentMatchesState.page = 1;
renderDynamicRecentMatchesPage();
});
prevButton.addEventListener("click", () => {
recentMatchesState.page -= 1;
renderDynamicRecentMatchesPage();
});
nextButton.addEventListener("click", () => {
recentMatchesState.page += 1;
renderDynamicRecentMatchesPage();
});
}
function hideLegacyPagination() {
const legacyPagination = document.getElementById(LEGACY_PAGINATION_ID);
if (legacyPagination) {
legacyPagination.hidden = true;
}
}
function renderDynamicPagination() {
ensureDynamicPaginationControls();
hideLegacyPagination();
const paginationNode = document.getElementById(LIVE_PAGINATION_ID);
const pageSizeSelect = document.getElementById("recent-matches-live-page-size");
const prevButton = document.getElementById("recent-matches-live-prev");
const nextButton = document.getElementById("recent-matches-live-next");
const pageLabel = document.getElementById("recent-matches-live-page-label");
if (!paginationNode || !pageSizeSelect || !prevButton || !nextButton || !pageLabel) return;
const totalItems = recentMatchesState.items.length;
const totalPages = getDynamicTotalPages();
recentMatchesState.page = clampDynamicPage(recentMatchesState.page, totalPages);
paginationNode.hidden = totalItems <= recentMatchesState.pageSize;
pageSizeSelect.value = String(recentMatchesState.pageSize);
prevButton.disabled = recentMatchesState.page <= 1;
nextButton.disabled = recentMatchesState.page >= totalPages;
pageLabel.textContent = `Pagina ${recentMatchesState.page} de ${totalPages}`;
}
function getDynamicTotalPages() {
return Math.max(1, Math.ceil(recentMatchesState.items.length / recentMatchesState.pageSize));
}
function clampDynamicPage(page, totalPages) {
const numericPage = Number(page);
if (!Number.isFinite(numericPage)) return 1;
return Math.min(Math.max(1, Math.trunc(numericPage)), totalPages);
}
function renderDynamicRecentMatchCard(item) {
const mapName = item?.map?.pretty_name || item?.map?.name || "Mapa no disponible";
const serverName = item?.server?.name || "Servidor no disponible";
const closedAt = item?.closed_at || item?.ended_at || item?.started_at;
const detailUrl = buildDynamicInternalMatchDetailUrl(item);
const actionLinks = [`<span class="historical-match-card__result">${escapeDynamicHtml(formatDynamicResultLabel(item?.result))}</span>`, detailUrl ? `<a class="historical-match-card__link" href="${escapeDynamicHtml(detailUrl)}">Ver detalles</a>` : ""].join("");
return `
<article class="historical-match-card historical-match-card--clean">
<div class="historical-match-card__top historical-match-card__top--clean">
<h3 class="historical-match-card__title">${escapeDynamicHtml(mapName)}</h3>
</div>
<div class="historical-match-meta historical-match-meta--clean">
<article>
<p class="historical-match-meta__label">Servidor</p>
<strong>${escapeDynamicHtml(serverName)}</strong>
</article>
<article>
<p class="historical-match-meta__label">Cierre</p>
<strong>${escapeDynamicHtml(formatDynamicTimestamp(closedAt))}</strong>
</article>
<article>
<p class="historical-match-meta__label">Jugadores</p>
<strong>${escapeDynamicHtml(formatDynamicNumber(item?.player_count))}</strong>
</article>
<article>
<p class="historical-match-meta__label">Marcador</p>
<strong>${escapeDynamicHtml(formatDynamicScore(item?.result))}</strong>
</article>
<article class="historical-match-card__actions-cell" aria-label="Acciones de la partida">
<div class="historical-match-card__actions">
${actionLinks}
</div>
</article>
</div>
</article>
`;
}
function readServerFromUrl() {
return new URLSearchParams(window.location.search).get("server") || "all-servers";
}
function normalizeDynamicServerSlug(value) {
const normalized = String(value || "").trim();
if (["comunidad-hispana-01", "comunidad-hispana-02", "all-servers"].includes(normalized)) return normalized;
return "all-servers";
}
function buildDynamicRecentMeta(items) {
const newest = items[0]?.closed_at || items[0]?.ended_at || items[0]?.started_at;
return newest ? `Actualizado: ${formatDynamicTimestamp(newest)}` : "Actualizado recientemente";
}
function setDynamicState(node, message, isError = false) {
node.textContent = message;
node.hidden = false;
node.classList.toggle("is-error", Boolean(isError));
}
function formatDynamicTimestamp(value) {
if (!value) return "Fecha no disponible";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat("es-ES", { day: "numeric", month: "numeric", year: "2-digit", hour: "2-digit", minute: "2-digit" }).format(date);
}
function formatDynamicNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? new Intl.NumberFormat("es-ES").format(number) : "0";
}
function formatDynamicScore(result) {
const allied = result?.allied_score;
const axis = result?.axis_score;
if (Number.isFinite(Number(allied)) && Number.isFinite(Number(axis))) return `${allied} - ${axis}`;
return "- - -";
}
function formatDynamicResultLabel(result) {
const winner = String(result?.winner || "").toLowerCase();
if (winner === "allies" || winner === "allied") return "Victoria aliada";
if (winner === "axis") return "Victoria axis";
return "Empate";
}
function buildDynamicInternalMatchDetailUrl(item) {
const serverSlug = item?.server?.slug;
const matchId = item?.internal_detail_match_id || item?.match_id;
if (!serverSlug || matchId === undefined || matchId === null) return "";
return `./historico-partida.html?server=${encodeURIComponent(String(serverSlug))}&match=${encodeURIComponent(String(matchId))}`;
}
function normalizeDynamicExternalMatchUrl(value) {
if (typeof value !== "string" || !value.trim()) return "";
try {
const url = new URL(value.trim());
return ["http:", "https:"].includes(url.protocol) ? url.href : "";
} catch (error) {
return "";
}
}
function escapeDynamicHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
})();

File diff suppressed because it is too large Load Diff

611
frontend/assets/js/main.js Normal file
View File

@@ -0,0 +1,611 @@
// Progressive enhancement for local frontend-backend checks.
const DEFAULT_SERVER_POLL_INTERVAL_MS = 300 * 1000;
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([
{
name: "LCM",
badge: "Clan CH",
description:
"Clan activo de la comunidad, con acceso directo a su discord.",
logoSrc: "./assets/img/clans/lcm.png",
logoAlt: "Logo de LCM",
logoClassName: "",
discordUrl: "https://discord.gg/9F9S353QZv",
discordLabel: "Abrir Discord",
},
{
name: "La 129",
badge: "Clan CH",
description:
"Clan activo de la comunidad.",
logoSrc: "./assets/img/clans/la129.png",
logoAlt: "Logo de La 129",
logoClassName: "clan-card__logo--wide",
discordUrl: "",
discordLabel: "Proximamente",
},
{
name: "250 Hispania",
badge: "Clan CH",
description:
"Clan activo de la comunidad, con acceso directo a su discord.",
logoSrc: "./assets/img/clans/250hispania-shield.png",
logoAlt: "Escudo de 250 Hispania",
logoClassName: "clan-card__logo--shield",
discordUrl: "https://discord.gg/3E62Yb6Aw3",
discordLabel: "Abrir Discord",
},
{
name: "H9H",
badge: "Clan CH",
description:
"Clan activo de la comunidad, con acceso directo a su discord.",
logoSrc: "./assets/img/clans/h9h.png",
logoAlt: "",
logoClassName: "",
discordUrl: "https://discord.gg/tYnXK7MQjB",
discordLabel: "Abrir Discord",
placeholderLabel: "H9H",
},
{
name: "BxB",
badge: "Clan CH",
description:
"Clan activo de la comunidad.",
logoSrc: "./assets/img/clans/bxb.png",
logoAlt: "Logo de BxB",
logoClassName: "",
discordUrl: "",
discordLabel: "Proximamente",
},
{
name: "7dv",
badge: "Clan CH",
description:
"Clan activo de la comunidad, con acceso directo a su discord.",
logoSrc: "./assets/img/clans/7dv.png",
logoAlt: "Logo de 7dv",
logoClassName: "",
discordUrl: "https://discord.gg/3sxNQZwrg6",
discordLabel: "Abrir Discord",
},
]);
document.addEventListener("DOMContentLoaded", () => {
console.info("HLL Vietnam frontend ready");
const backendBaseUrl =
document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
const serverPollIntervalMs = getServerPollIntervalMs(
document.body.dataset.serverRefreshMs,
);
const statusNode = document.getElementById("backend-status");
const trailerFrame = document.getElementById("trailer-frame");
const trailerTitle = document.getElementById("trailer-title");
const serversTitle = document.getElementById("servers-title");
const serversList = document.getElementById("servers-list");
const serversBadge = document.getElementById("servers-badge");
const communityClansList = document.getElementById("community-clans-list");
updateBackendStatus(statusNode, "Backend comprobando", "status-chip--idle");
setServersDataState(serversBadge, { timestampLabel: "" });
renderServersLoadingState(serversList);
hydrateCommunityClans(communityClansList);
let serverRefreshInFlight = false;
const refreshServers = async () => {
if (serverRefreshInFlight) {
return;
}
serverRefreshInFlight = true;
try {
await hydrateServers(
backendBaseUrl,
serversTitle,
serversList,
serversBadge,
);
} finally {
serverRefreshInFlight = false;
}
};
Promise.allSettled([
fetchHealth(backendBaseUrl, statusNode),
hydrateTrailer(backendBaseUrl, trailerFrame, trailerTitle),
refreshServers(),
]).catch((error) => {
console.warn("Progressive enhancement failed", error);
});
if (serverPollIntervalMs > 0) {
window.setInterval(() => {
void refreshServers();
}, serverPollIntervalMs);
}
});
async function fetchHealth(backendBaseUrl, statusNode) {
try {
const response = await fetch(`${backendBaseUrl}/health`);
if (!response.ok) {
throw new Error(`Health request failed with ${response.status}`);
}
const payload = await response.json();
if (payload.status === "ok") {
updateBackendStatus(statusNode, "Backend operativo", "status-chip--ok");
return;
}
throw new Error("Unexpected health payload");
} catch (error) {
console.warn("Backend health check unavailable", error);
updateBackendStatus(
statusNode,
"Modo estatico activo",
"status-chip--fallback",
);
}
}
async function hydrateTrailer(backendBaseUrl, trailerFrame, trailerTitle) {
if (!trailerFrame || !trailerTitle) {
return;
}
try {
const response = await fetch(`${backendBaseUrl}/api/trailer`);
if (!response.ok) {
throw new Error(`Trailer request failed with ${response.status}`);
}
const payload = await response.json();
const trailer = payload.data;
if (!trailer || !trailer.video_url || !trailer.title) {
throw new Error("Trailer payload incomplete");
}
trailerFrame.src = trailer.video_url;
trailerFrame.title = trailer.title;
trailerTitle.textContent = trailer.title;
} catch (error) {
console.warn("Trailer placeholder remains static", error);
}
}
async function hydrateServers(
backendBaseUrl,
serversTitle,
serversList,
serversBadge,
) {
if (!serversTitle || !serversList || !serversBadge) {
return;
}
try {
const payload = await fetchJson(`${backendBaseUrl}/api/servers`);
const serversData = payload.data;
if (!serversData || !Array.isArray(serversData.items)) {
throw new Error("Servers payload incomplete");
}
serversTitle.textContent =
serversData.title || "Estado actual de servidores";
setServersDataState(serversBadge, deriveSnapshotState(serversData));
if (serversData.items.length === 0) {
serversList.innerHTML =
'<p class="servers-empty">Informacion de servidores disponible mas adelante.</p>';
return;
}
const visibleItems = selectPrimaryServerItems(serversData.items);
serversList.innerHTML = renderServerSections(visibleItems);
} catch (error) {
console.warn("Servers panel failed to hydrate with live data", error);
serversList.innerHTML =
'<p class="servers-empty">No se pudo cargar el estado real de servidores en este momento.</p>';
setServersDataState(serversBadge, {
label: "Actualizacion no disponible",
isFresh: false,
});
}
}
function renderServersLoadingState(serversList) {
if (!serversList) {
return;
}
serversList.innerHTML = `
<div class="servers-loading">
<span class="servers-loading__pulse"></span>
<p>Cargando estado real de servidores...</p>
</div>
`;
}
function updateBackendStatus(statusNode, label, stateClass) {
if (!statusNode) {
return;
}
statusNode.textContent = label;
statusNode.classList.remove("status-chip--ok", "status-chip--fallback");
if (stateClass) {
statusNode.classList.add(stateClass);
}
}
function setServersDataState(badgeNode, state) {
if (!badgeNode) {
return;
}
const hasLabel = typeof state.label === "string" && state.label;
badgeNode.textContent = hasLabel
? state.label
: "Actualizado no disponible";
badgeNode.classList.toggle("status-chip--ok", Boolean(hasLabel && state.isFresh));
badgeNode.classList.toggle(
"status-chip--fallback",
!hasLabel || !state.isFresh,
);
}
function renderServerStatsCard(server) {
const serverName = server.server_name || "Servidor sin nombre";
const statusLabel = formatServerStatus(server.status);
const stateClass =
server.status === "online" ? "server-state--online" : "server-state--offline";
const isRealSnapshot = isRealLiveSnapshot(server);
const currentMap = server.current_map || "Sin mapa disponible";
const region = normalizeServerRegion(server.region);
const players = Number.isFinite(server.players) ? server.players : 0;
const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0;
const actionMarkup = renderServerAction(server);
const cardVariantClass = isRealSnapshot ? "server-card--real" : "server-card--reference";
const eyebrowLabel = isRealSnapshot ? "Servidor de comunidad" : "Referencia actual";
const quickFactItems = [
{ label: "Mapa", value: currentMap, valueClassName: "server-card__quickfact-value--map" },
];
if (region) {
quickFactItems.push({ label: "Region", value: region });
}
const quickFacts = renderQuickFacts(quickFactItems);
return `
<article class="server-card server-card--stats ${cardVariantClass}">
<div class="server-card__top server-card__top--stats">
<div class="server-card__identity">
<p class="server-card__eyebrow">${escapeHtml(eyebrowLabel)}</p>
<h3>${escapeHtml(serverName)}</h3>
</div>
<div class="server-card__status-column">
<span class="server-state ${stateClass}">${escapeHtml(statusLabel)}</span>
<p class="server-card__population">${escapeHtml(`${players} / ${maxPlayers}`)}</p>
</div>
</div>
<div class="server-card__bottom">
${quickFacts}
${actionMarkup}
</div>
</article>
`;
}
function renderServerSections(latestItems) {
return latestItems.map((server) => renderServerStatsCard(server)).join("");
}
function normalizeServerRegion(value) {
if (typeof value !== "string") {
return "";
}
const trimmedValue = value.trim();
if (!trimmedValue) {
return "";
}
const normalizedValue = trimmedValue
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
const placeholderValues = new Set([
"region pendiente",
"region pending",
"pending",
"unknown",
"desconocida",
"no disponible",
"por confirmar",
"n/a",
]);
return placeholderValues.has(normalizedValue) ? "" : trimmedValue;
}
function renderServerAction(server) {
const actions = getTrustedServerActions(server);
if (!actions) {
return "";
}
return `
<div class="server-card__actions">
<a class="server-action-link" href="${escapeHtml(actions.historicalUrl)}">
Historico
</a>
<a class="server-action-link" href="${escapeHtml(actions.currentMatchUrl)}">
Partida actual
</a>
</div>
`;
}
function hydrateCommunityClans(listNode) {
if (!listNode) {
return;
}
listNode.innerHTML = shuffleItems(COMMUNITY_CLANS)
.map((clan) => renderCommunityClanCard(clan))
.join("");
}
function renderCommunityClanCard(clan) {
const logoMarkup = renderClanLogo(clan);
const discordMarkup = renderClanDiscordLink(clan);
return `
<article class="clan-card">
<div class="clan-card__brand">
${logoMarkup}
<div class="clan-card__copy">
<p class="clan-card__eyebrow">${escapeHtml(clan.badge)}</p>
<h3>${escapeHtml(clan.name)}</h3>
<p>${escapeHtml(clan.description)}</p>
</div>
</div>
${discordMarkup}
</article>
`;
}
function renderClanLogo(clan) {
const logoClassNames = ["clan-card__logo"];
if (clan.logoClassName) {
logoClassNames.push(clan.logoClassName);
}
if (clan.logoSrc) {
return `
<div class="${escapeHtml(logoClassNames.join(" "))}">
<img
src="${escapeHtml(clan.logoSrc)}"
alt="${escapeHtml(clan.logoAlt)}"
decoding="async"
/>
</div>
`;
}
return `
<div class="${escapeHtml(logoClassNames.join(" "))}">
<div class="clan-card__logo-placeholder" aria-label="Logo pendiente de ${escapeHtml(clan.name)}">
${escapeHtml(clan.placeholderLabel || clan.name)}
</div>
</div>
`;
}
function renderClanDiscordLink(clan) {
if (!clan.discordUrl) {
return `
<span
class="server-action-link server-action-link--disabled clan-card__link"
aria-disabled="true"
>
${escapeHtml(clan.discordLabel)}
</span>
`;
}
return `
<a
class="server-action-link clan-card__link"
href="${escapeHtml(clan.discordUrl)}"
target="_blank"
rel="noreferrer"
>
${escapeHtml(clan.discordLabel)}
</a>
`;
}
function renderQuickFacts(items) {
return `
<div class="server-card__quickfacts">
${items
.map(
(item) => `
<article class="server-card__quickfact">
<p>${escapeHtml(item.label)}</p>
<strong class="${escapeHtml(item.valueClassName || "")}">${escapeHtml(item.value)}</strong>
</article>
`,
)
.join("")}
</div>
`;
}
function getTrustedServerActions(server) {
const trustedActionKey = resolveTrustedServerActionKey(server);
return TRUSTED_SERVER_ACTIONS[trustedActionKey] || null;
}
function resolveTrustedServerActionKey(server) {
if (!server) {
return "";
}
const externalServerId = getTrimmedServerValue(server.external_server_id);
if (TRUSTED_SERVER_ACTIONS[externalServerId]) {
return externalServerId;
}
const trustedSlugFields = [
server.server_slug,
server.target_key,
server.slug,
server.community_slug,
];
const trustedSlug = trustedSlugFields
.map(getTrimmedServerValue)
.find((value) => TRUSTED_SERVER_ACTIONS[value]);
if (trustedSlug) {
return trustedSlug;
}
const serverNames = [server.server_name, server.name].map(getTrimmedServerValue);
if (
serverNames.some(
(name) => name.startsWith("#01") || name.includes("Comunidad Hispana #01"),
)
) {
return "comunidad-hispana-01";
}
if (
serverNames.some(
(name) => name.startsWith("#02") || name.includes("Comunidad Hispana #02"),
)
) {
return "comunidad-hispana-02";
}
const serverReference = [
getTrimmedServerValue(server.source_ref),
externalServerId,
].join(" ");
if (serverReference.includes("152.114.195.174") || serverReference.includes(":7779")) {
return "comunidad-hispana-01";
}
if (serverReference.includes("152.114.195.150") || serverReference.includes(":7879")) {
return "comunidad-hispana-02";
}
return "";
}
function getTrimmedServerValue(value) {
return typeof value === "string" ? value.trim() : "";
}
function selectPrimaryServerItems(items) {
if (!Array.isArray(items)) {
return [];
}
const realItems = items.filter(isRealLiveSnapshot);
return realItems.length > 0 ? realItems : items;
}
function isRealLiveSnapshot(item) {
return item?.snapshot_origin === "real-a2s" || item?.snapshot_origin === "real-rcon";
}
function deriveSnapshotState(serversData) {
const timestampLabel = serversData?.last_snapshot_at
? formatTimestamp(serversData.last_snapshot_at)
: "";
if (!timestampLabel) {
return {
label: "",
isFresh: false,
};
}
const isFresh = serversData?.is_stale !== true;
return {
label: isFresh
? `Actualizado ${timestampLabel}`
: `Ultimo snapshot ${timestampLabel}`,
isFresh,
};
}
function formatServerStatus(status) {
if (status === "online") {
return "Online";
}
if (status === "offline") {
return "Offline";
}
return "Estado pendiente";
}
function formatTimestamp(timestamp) {
const value = new Date(timestamp);
if (Number.isNaN(value.getTime())) {
return "Fecha no disponible";
}
return new Intl.DateTimeFormat("es-ES", {
dateStyle: "short",
timeStyle: "short",
}).format(value);
}
function getServerPollIntervalMs(rawValue) {
const parsedValue = Number(rawValue);
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
return DEFAULT_SERVER_POLL_INTERVAL_MS;
}
return parsedValue;
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`);
}
return response.json();
}
function shuffleItems(items) {
const shuffledItems = [...items];
for (let currentIndex = shuffledItems.length - 1; currentIndex > 0; currentIndex -= 1) {
const randomIndex = Math.floor(Math.random() * (currentIndex + 1));
[shuffledItems[currentIndex], shuffledItems[randomIndex]] = [
shuffledItems[randomIndex],
shuffledItems[currentIndex],
];
}
return shuffledItems;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

View File

@@ -0,0 +1,922 @@
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_WHITE_WEAPON_ICON_PATH = "./assets/img/weapons/white/";
const CURRENT_MATCH_WHITE_WEAPON_ICON_FILES = Object.freeze([
"bazooka_white.svg",
"bren_gun_white.svg",
"browing_m1919_white.svg",
"colt_1911_white.svg",
"dp27_white.svg",
"flammenwefer41_white.svg",
"gewehr_white.svg",
"kar98k_white.svg",
"kar98k_x8_white.svg",
"lee_enfield_n4_white.svg",
"luger_p08_white.svg",
"m1903_springfield_white.svg",
"m1_carabine_white.svg",
"m1_garand_white.svg",
"m2_flamethrower_white.svg",
"m3_grease_gun_white.svg",
"m97_white.svg",
"mg34_white.svg",
"mg42_white.svg",
"mosing_nagant_1891_white.svg",
"mosing_nagant_9130_white.svg",
"mosing_nagant_m38_white.svg",
"mp40_white.svg",
"nagant_m1895_white.svg",
"panzerchreck_white.svg",
"piat_white.svg",
"ppsh41_white.svg",
"ppsh_41w_drum_white.svg",
"ptrs41_white.svg",
"scoped_mosin_nagant_9130_white.svg",
"scoped_svt40_white.svg",
"sten_mk_v_white.svg",
"stg44_white.svg",
"svt40_white.svg",
"thompson_white.svg",
"tokarev_tt33_white.svg",
"walther_p38_white.svg",
"webley_revolver_white.svg",
]);
const CURRENT_MATCH_WEAPONS = Object.freeze({
bazooka: currentMatchWeapon("Bazooka", "bazooka_white.svg"),
"m1 bazooka": currentMatchWeapon("M1 Bazooka", "bazooka_white.svg"),
"us bazooka": currentMatchWeapon("M1 Bazooka", "bazooka_white.svg"),
bren: currentMatchWeapon("Bren Gun", "bren_gun_white.svg"),
"bren gun": currentMatchWeapon("Bren Gun", "bren_gun_white.svg"),
m1919: currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"),
"m1919 browning": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"),
"browning m1919": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"),
browning: currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"),
"us tank machine gun": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"),
"us coaxial mg": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"),
"us vehicle mg": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"),
"coaxial m1919": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"),
"m1919 coaxial": currentMatchWeapon("Browning M1919", "browing_m1919_white.svg"),
colt: currentMatchWeapon("Colt 1911", "colt_1911_white.svg"),
"colt 1911": currentMatchWeapon("Colt 1911", "colt_1911_white.svg"),
"colt m1911": currentMatchWeapon("Colt 1911", "colt_1911_white.svg"),
m1911: currentMatchWeapon("Colt 1911", "colt_1911_white.svg"),
"m1911 pistol": currentMatchWeapon("Colt 1911", "colt_1911_white.svg"),
dp27: currentMatchWeapon("DP-27", "dp27_white.svg"),
"dp 27": currentMatchWeapon("DP-27", "dp27_white.svg"),
"dp 27 lmg": currentMatchWeapon("DP-27", "dp27_white.svg"),
"flammenwerfer 41": currentMatchWeapon("Flammenwerfer 41", "flammenwefer41_white.svg"),
flammenwerfer: currentMatchWeapon("Flammenwerfer 41", "flammenwefer41_white.svg"),
flammenwefer41: currentMatchWeapon("Flammenwerfer 41", "flammenwefer41_white.svg"),
"german flamethrower": currentMatchWeapon("Flammenwerfer 41", "flammenwefer41_white.svg"),
"gewehr 43": currentMatchWeapon("Gewehr 43", "gewehr_white.svg"),
gewehr43: currentMatchWeapon("Gewehr 43", "gewehr_white.svg"),
g43: currentMatchWeapon("Gewehr 43", "gewehr_white.svg"),
kar98k: currentMatchWeapon("Kar98k", "kar98k_white.svg"),
"kar 98k": currentMatchWeapon("Kar98k", "kar98k_white.svg"),
kar98: currentMatchWeapon("Kar98k", "kar98k_white.svg"),
k98: currentMatchWeapon("Kar98k", "kar98k_white.svg"),
"k98k": currentMatchWeapon("Kar98k", "kar98k_white.svg"),
"scoped kar98k": currentMatchWeapon("Scoped Kar98k", "kar98k_x8_white.svg"),
"kar98k x8": currentMatchWeapon("Scoped Kar98k", "kar98k_x8_white.svg"),
"kar 98k x8": currentMatchWeapon("Scoped Kar98k", "kar98k_x8_white.svg"),
"german sniper kar98k": currentMatchWeapon("Scoped Kar98k", "kar98k_x8_white.svg"),
"sniper kar98k": currentMatchWeapon("Scoped Kar98k", "kar98k_x8_white.svg"),
"lee enfield no 4": currentMatchWeapon("Lee-Enfield No.4", "lee_enfield_n4_white.svg"),
"lee enfield no4": currentMatchWeapon("Lee-Enfield No.4", "lee_enfield_n4_white.svg"),
"lee enfield": currentMatchWeapon("Lee-Enfield No.4", "lee_enfield_n4_white.svg"),
enfield: currentMatchWeapon("Lee-Enfield No.4", "lee_enfield_n4_white.svg"),
luger: currentMatchWeapon("Luger P08", "luger_p08_white.svg"),
p08: currentMatchWeapon("Luger P08", "luger_p08_white.svg"),
"luger p08": currentMatchWeapon("Luger P08", "luger_p08_white.svg"),
"m1903 springfield": currentMatchWeapon("M1903 Springfield", "m1903_springfield_white.svg"),
springfield: currentMatchWeapon("M1903 Springfield", "m1903_springfield_white.svg"),
"us sniper springfield": currentMatchWeapon("M1903 Springfield", "m1903_springfield_white.svg"),
"scoped springfield": currentMatchWeapon("M1903 Springfield", "m1903_springfield_white.svg"),
"m1 carbine": currentMatchWeapon("M1 Carbine", "m1_carabine_white.svg"),
"m1 carabine": currentMatchWeapon("M1 Carbine", "m1_carabine_white.svg"),
"m1 garand": currentMatchWeapon("M1 Garand", "m1_garand_white.svg"),
garand: currentMatchWeapon("M1 Garand", "m1_garand_white.svg"),
"m2 flamethrower": currentMatchWeapon("M2 Flamethrower", "m2_flamethrower_white.svg"),
"us flamethrower": currentMatchWeapon("M2 Flamethrower", "m2_flamethrower_white.svg"),
"m3 grease gun": currentMatchWeapon("M3 Grease Gun", "m3_grease_gun_white.svg"),
"grease gun": currentMatchWeapon("M3 Grease Gun", "m3_grease_gun_white.svg"),
m97: currentMatchWeapon("Winchester M97", "m97_white.svg"),
"winchester m97": currentMatchWeapon("Winchester M97", "m97_white.svg"),
"trench gun": currentMatchWeapon("Winchester M97", "m97_white.svg"),
shotgun: currentMatchWeapon("Winchester M97", "m97_white.svg"),
mg34: currentMatchWeapon("MG34", "mg34_white.svg"),
"mg 34": currentMatchWeapon("MG34", "mg34_white.svg"),
"german tank machine gun": currentMatchWeapon("MG34", "mg34_white.svg"),
"german coaxial mg": currentMatchWeapon("MG34", "mg34_white.svg"),
"german vehicle mg": currentMatchWeapon("MG34", "mg34_white.svg"),
"coaxial mg34": currentMatchWeapon("MG34", "mg34_white.svg"),
"mg34 coaxial": currentMatchWeapon("MG34", "mg34_white.svg"),
mg42: currentMatchWeapon("MG42", "mg42_white.svg"),
"mg 42": currentMatchWeapon("MG42", "mg42_white.svg"),
"mosin nagant 1891": currentMatchWeapon("Mosin Nagant 1891", "mosing_nagant_1891_white.svg"),
"mosin 1891": currentMatchWeapon("Mosin Nagant 1891", "mosing_nagant_1891_white.svg"),
"mosin nagant 91 30": currentMatchWeapon("Mosin Nagant 91/30", "mosing_nagant_9130_white.svg"),
"mosin 9130": currentMatchWeapon("Mosin Nagant 91/30", "mosing_nagant_9130_white.svg"),
"mosin nagant 9130": currentMatchWeapon("Mosin Nagant 91/30", "mosing_nagant_9130_white.svg"),
"mosin nagant m38": currentMatchWeapon("Mosin Nagant M38", "mosing_nagant_m38_white.svg"),
"mosin m38": currentMatchWeapon("Mosin Nagant M38", "mosing_nagant_m38_white.svg"),
m38: currentMatchWeapon("Mosin Nagant M38", "mosing_nagant_m38_white.svg"),
mp40: currentMatchWeapon("MP40", "mp40_white.svg"),
"mp 40": currentMatchWeapon("MP40", "mp40_white.svg"),
"nagant m1895": currentMatchWeapon("Nagant M1895", "nagant_m1895_white.svg"),
"nagant revolver": currentMatchWeapon("Nagant M1895", "nagant_m1895_white.svg"),
panzerschreck: currentMatchWeapon("Panzerschreck", "panzerchreck_white.svg"),
panzerchreck: currentMatchWeapon("Panzerschreck", "panzerchreck_white.svg"),
raketenpanzerbuchse: currentMatchWeapon("Panzerschreck", "panzerchreck_white.svg"),
piat: currentMatchWeapon("PIAT", "piat_white.svg"),
"ppsh 41": currentMatchWeapon("PPSh-41", "ppsh41_white.svg"),
ppsh41: currentMatchWeapon("PPSh-41", "ppsh41_white.svg"),
ppsh: currentMatchWeapon("PPSh-41", "ppsh41_white.svg"),
"ppsh 41 drum": currentMatchWeapon("PPSh-41 Drum", "ppsh_41w_drum_white.svg"),
"ppsh 41 w drum": currentMatchWeapon("PPSh-41 Drum", "ppsh_41w_drum_white.svg"),
"ppsh drum": currentMatchWeapon("PPSh-41 Drum", "ppsh_41w_drum_white.svg"),
"ppsh41 drum": currentMatchWeapon("PPSh-41 Drum", "ppsh_41w_drum_white.svg"),
"ptrs 41": currentMatchWeapon("PTRS-41", "ptrs41_white.svg"),
ptrs41: currentMatchWeapon("PTRS-41", "ptrs41_white.svg"),
ptrs: currentMatchWeapon("PTRS-41", "ptrs41_white.svg"),
"scoped mosin nagant 91 30": currentMatchWeapon("Scoped Mosin Nagant 91/30", "scoped_mosin_nagant_9130_white.svg"),
"scoped mosin nagant 9130": currentMatchWeapon("Scoped Mosin Nagant 91/30", "scoped_mosin_nagant_9130_white.svg"),
"soviet sniper mosin": currentMatchWeapon("Scoped Mosin Nagant 91/30", "scoped_mosin_nagant_9130_white.svg"),
"sniper mosin": currentMatchWeapon("Scoped Mosin Nagant 91/30", "scoped_mosin_nagant_9130_white.svg"),
"scoped svt 40": currentMatchWeapon("Scoped SVT-40", "scoped_svt40_white.svg"),
"scoped svt40": currentMatchWeapon("Scoped SVT-40", "scoped_svt40_white.svg"),
"svt40 scoped": currentMatchWeapon("Scoped SVT-40", "scoped_svt40_white.svg"),
"sten mk v": currentMatchWeapon("Sten Mk V", "sten_mk_v_white.svg"),
sten: currentMatchWeapon("Sten Mk V", "sten_mk_v_white.svg"),
stg44: currentMatchWeapon("StG 44", "stg44_white.svg"),
"stg 44": currentMatchWeapon("StG 44", "stg44_white.svg"),
"sturmgewehr 44": currentMatchWeapon("StG 44", "stg44_white.svg"),
"svt 40": currentMatchWeapon("SVT-40", "svt40_white.svg"),
svt40: currentMatchWeapon("SVT-40", "svt40_white.svg"),
"m1a1 thompson": currentMatchWeapon("M1A1 Thompson", "thompson_white.svg"),
m1a1: currentMatchWeapon("M1A1 Thompson", "thompson_white.svg"),
"m1928 thompson": currentMatchWeapon("M1928 Thompson", "thompson_white.svg"),
thompson: currentMatchWeapon("Thompson", "thompson_white.svg"),
"tokarev tt 33": currentMatchWeapon("Tokarev TT-33", "tokarev_tt33_white.svg"),
"tokarev tt33": currentMatchWeapon("Tokarev TT-33", "tokarev_tt33_white.svg"),
tt33: currentMatchWeapon("Tokarev TT-33", "tokarev_tt33_white.svg"),
"walther p38": currentMatchWeapon("Walther P38", "walther_p38_white.svg"),
p38: currentMatchWeapon("Walther P38", "walther_p38_white.svg"),
webley: currentMatchWeapon("Webley Revolver", "webley_revolver_white.svg"),
"webley revolver": currentMatchWeapon("Webley Revolver", "webley_revolver_white.svg"),
unknown: { label: "Arma desconocida", icon: "" },
});
validateCurrentMatchWeaponMapping();
function currentMatchWeapon(label, fileName) {
return {
label,
icon: `${CURRENT_MATCH_WHITE_WEAPON_ICON_PATH}${fileName}`,
};
}
function validateCurrentMatchWeaponMapping() {
const expectedIcons = new Set(CURRENT_MATCH_WHITE_WEAPON_ICON_FILES);
const mappedIcons = new Set();
const invalidIcons = [];
Object.entries(CURRENT_MATCH_WEAPONS).forEach(([alias, weapon]) => {
if (!weapon.icon) {
return;
}
if (!weapon.icon.startsWith(CURRENT_MATCH_WHITE_WEAPON_ICON_PATH)) {
invalidIcons.push(`${alias}: ${weapon.icon}`);
return;
}
const fileName = weapon.icon.slice(CURRENT_MATCH_WHITE_WEAPON_ICON_PATH.length);
mappedIcons.add(fileName);
if (!expectedIcons.has(fileName)) {
invalidIcons.push(`${alias}: ${weapon.icon}`);
}
});
const unmappedIcons = [...expectedIcons].filter((fileName) => !mappedIcons.has(fileName));
if (unmappedIcons.length > 0 || invalidIcons.length > 0) {
console.warn("Current match weapon icon mapping needs review.", {
unmappedIcons,
invalidIcons,
});
}
}
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;
}
nodes.history.href = `./historico.html?server=${encodeURIComponent(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) {
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 cursor = killFeedState.latestEventId
? `&since_event_id=${encodeURIComponent(killFeedState.latestEventId)}`
: "";
const payload = await fetchJson(
`${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=${CURRENT_MATCH_KILL_FEED_LIMIT}${cursor}`,
);
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.";
nodes.scoreboard.href = scoreboardUrl || "./index.html";
nodes.scoreboard.hidden = !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 = "";
nodes.scoreboard.hidden = 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",
`
<p class="historical-state" id="current-match-feed-state" aria-live="polite">
Cargando feed de combate...
</p>
<section class="current-match-killfeed-screen" aria-label="Bajas recientes en la partida actual">
<div class="current-match-killfeed" id="current-match-feed-list"></div>
</section>
`,
);
}
nodes.feedState = document.getElementById("current-match-feed-state");
nodes.feedList = document.getElementById("current-match-feed-list");
return {
byId: new Map(),
latestEventId: "",
visibleSignature: "",
};
}
function initializePlayerStats(nodes) {
const shell = nodes.playersTitle?.closest(".panel__shell");
if (shell) {
shell.insertAdjacentHTML(
"beforeend",
`
<p class="historical-state" id="current-match-player-stats-state" aria-live="polite">
Cargando estadisticas en vivo...
</p>
<div class="historical-table-shell" id="current-match-player-stats-shell" hidden></div>
`,
);
}
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) {
const incoming = Array.isArray(data.items) ? data.items : [];
if (data.scope === "no-current-match-events") {
state.byId.clear();
state.latestEventId = "";
}
incoming.forEach((event) => {
if (event?.event_id) {
state.byId.set(event.event_id, event);
}
});
const events = [...state.byId.values()]
.sort(compareKillFeedEvents)
.slice(-CURRENT_MATCH_KILL_FEED_LIMIT);
state.byId = new Map(events.map((event) => [event.event_id, event]));
state.latestEventId = events[events.length - 1]?.event_id || state.latestEventId;
if (events.length === 0) {
nodes.feedList.innerHTML = "";
state.visibleSignature = "";
setState(nodes.feedState, "Todavía no se han detectado bajas en esta partida.");
return;
}
const visualEvents = events;
const visibleSignature = visualEvents.map((event) => event.event_id).join("|");
if (visibleSignature !== state.visibleSignature) {
nodes.feedList.innerHTML = renderKillFeedColumns(visualEvents);
state.visibleSignature = visibleSignature;
}
nodes.feedState.textContent = formatKillFeedCoverage(data.scope);
nodes.feedState.classList.remove("historical-state--error");
}
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) => `
<div class="current-match-killfeed__column">
${columnEvents.map(renderKillFeedRow).join("")}
</div>
`,
)
.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
? '<span class="current-match-killfeed__teamkill">TK</span>'
: "";
return `
<article
class="current-match-killfeed__row${event.is_teamkill ? " is-teamkill" : ""}"
data-event-id="${escapeHtml(event.event_id || "")}"
>
<span class="current-match-killfeed__player current-match-killfeed__player--killer">
<span class="current-match-killfeed__player-identity">
<strong class="current-match-killfeed__player-name" title="${escapeHtml(event.killer_name || "Jugador no disponible")}">
${escapeHtml(event.killer_name || "Jugador no disponible")}
</strong>
${renderKillFeedTeamBadge(killerTeam)}
</span>
<span class="current-match-killfeed__player-meta">
${teamkillBadge}
</span>
</span>
<span
class="current-match-killfeed__weapon"
title="${escapeHtml(weapon.label)}"
aria-label="${escapeHtml(weapon.label)}"
>
${renderKillFeedWeaponIcon(weapon)}
<em>${escapeHtml(weapon.label)}</em>
</span>
<span class="current-match-killfeed__player current-match-killfeed__player--victim">
<span class="current-match-killfeed__player-identity">
<span class="current-match-killfeed__player-name" title="${escapeHtml(event.victim_name || "Objetivo no disponible")}">
${escapeHtml(event.victim_name || "Objetivo no disponible")}
</span>
${renderKillFeedTeamBadge(victimTeam)}
</span>
</span>
</article>
`;
}
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 `
<span class="historical-player-team-badge historical-player-team-badge--${team.key} current-match-killfeed__team-badge">
${escapeHtml(team.label)}
</span>
`;
}
function resolveKillFeedWeapon(value) {
const key = normalizeLookupText(value);
return CURRENT_MATCH_WEAPONS[key] || {
label: String(value || CURRENT_MATCH_WEAPONS.unknown.label),
icon: CURRENT_MATCH_WEAPONS.unknown.icon,
};
}
function renderKillFeedWeaponIcon(weapon) {
if (!weapon.icon) {
return '<span class="current-match-killfeed__weapon-fallback" aria-hidden="true">?</span>';
}
return `
<img
class="current-match-killfeed__weapon-icon"
src="${escapeHtml(weapon.icon)}"
alt=""
width="88"
height="32"
loading="lazy"
decoding="async"
onerror="this.hidden = true; this.nextElementSibling.hidden = false;"
/>
<span class="current-match-killfeed__weapon-fallback" aria-hidden="true" hidden>?</span>
`;
}
function renderPlayerStats(data, nodes, state) {
const items = Array.isArray(data.items) ? sortPlayerStats(data.items) : [];
renderDetectedPlayerCount(items.length, nodes);
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;
setState(nodes.playerStatsState, "Estadisticas derivadas de los eventos recientes.");
}
function renderDetectedPlayerCount(count, nodes) {
if (nodes.playerCount) {
nodes.playerCount.textContent = `Jugadores detectados: ${count}`;
}
}
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 `
<table class="historical-table historical-table--players">
<thead>
<tr>
<th>Jugador</th>
<th>Equipo</th>
<th>Bajas</th>
<th>Muertes</th>
<th>TK</th>
<th>Muertes TK</th>
<th>Arma frecuente</th>
</tr>
</thead>
<tbody>
${items.map(renderPlayerStatsRow).join("")}
</tbody>
</table>
`;
}
function renderPlayerStatsRow(item) {
const team = getPlayerTeamDisplay(item.team);
return `
<tr class="historical-player-row historical-player-row--${team.key}">
<td>${escapeHtml(item.player_name || "Jugador no disponible")}</td>
<td class="historical-player-team-cell">
<span class="historical-player-team-badge historical-player-team-badge--${team.key}">
${escapeHtml(team.label)}
</span>
</td>
<td>${escapeHtml(formatStatNumber(item.kills))}</td>
<td>${escapeHtml(formatStatNumber(item.deaths))}</td>
<td>${escapeHtml(formatStatNumber(item.teamkills))}</td>
<td>${escapeHtml(formatStatNumber(item.deaths_by_teamkill))}</td>
<td>${escapeHtml(item.favorite_weapon || "No disponible")}</td>
</tr>
`;
}
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 `
<article>
<span>${escapeHtml(label)}</span>
<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 (!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 `
<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 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) {
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) {
node.textContent = message;
node.hidden = false;
node.classList.toggle("historical-state--error", isError);
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`);
}
return response.json();
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}