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"), 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 `
${renderScoreboardSide({ sideClass: "historical-scoreboard-side--allied", emblem: factions.allied.emblem, sideLabel: "Aliados", factionLabel: factions.allied.label, isWinner: isAlliedWinner, })}
${escapeHtml(formatDuration(item.duration_seconds))} ${escapeHtml(alliedScore)} : ${escapeHtml(axisScore)} ${escapeHtml(mapName)} ${escapeHtml(formatGameMode(item.game_mode || item.gamestate?.game_mode))} ${escapeHtml(formatWinner(winner))}
${renderScoreboardSide({ sideClass: "historical-scoreboard-side--axis", emblem: factions.axis.emblem, sideLabel: "Eje", factionLabel: factions.axis.label, isWinner: isAxisWinner, })}
${metadata.map(([label, value]) => renderCompactMeta(label, value)).join("")}
`; } function renderScoreboardSide({ sideClass, emblem, sideLabel, factionLabel, isWinner }) { const fallbackLabel = factionLabel || sideLabel; return `
${escapeHtml(fallbackLabel)}
${escapeHtml(sideLabel)} ${isWinner ? "Ganador" : ""}
`; } function renderCompactMeta(label, value) { return `
${escapeHtml(label)} ${escapeHtml(value || "No disponible")}
`; } 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.playersTableShell.hidden = true; nodes.playersBody.innerHTML = ""; return; } nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`; nodes.playersState.hidden = true; nodes.playersBody.innerHTML = players .map((player, index) => renderPlayerRows(player, item, index)) .join(""); bindPlayerDetailRows(nodes.playersBody); nodes.playersTableShell.hidden = false; } function renderPlayerRows(player, item, index) { const team = getTeamSideDisplay(player.team || player.team_side); const rowId = `match-player-row-${index}`; const panelId = `match-player-panel-${index}`; const playerName = player.player_name || player.name || "Jugador no identificado"; const kpm = formatKpm(player.kills, item.duration_seconds); return ` ${escapeHtml(team.label)} ${escapeHtml(formatOptionalNumber(player.kills))} ${escapeHtml(formatOptionalNumber(player.deaths))} ${escapeHtml(formatOptionalNumber(player.teamkills))} ${escapeHtml(formatKdRatio(player))} ${escapeHtml(kpm)} ${renderPlayerStatsPanel(player, item, { team, playerName, kpm })} `; } function bindPlayerDetailRows(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; } const setExpanded = (expanded) => { row.classList.toggle("is-expanded", expanded); detailRow.classList.toggle("is-open", expanded); row.setAttribute("aria-expanded", String(expanded)); button.setAttribute("aria-expanded", String(expanded)); }; const toggleExpanded = () => setExpanded(!detailRow.classList.contains("is-open")); row.addEventListener("click", (event) => { if (event.target.closest("a")) { return; } toggleExpanded(); }); row.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") { return; } event.preventDefault(); toggleExpanded(); }); }); } 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 `

${escapeHtml(context.team.label)}

${escapeHtml(context.playerName)}

${renderPlayerStatChip("K", formatOptionalNumber(player.kills))} ${renderPlayerStatChip("D", formatOptionalNumber(player.deaths))} ${renderPlayerStatChip("TK", formatOptionalNumber(player.teamkills))} ${renderPlayerStatChip("K/D", formatKdRatio(player))} ${renderPlayerStatChip("KPM", context.kpm)}
${ hasExpandedStats ? `
${renderNamedCountSection("Armas", player.top_weapons)} ${renderNamedCountSection("Mas abatido", player.most_killed)} ${renderNamedCountSection("Muere por", player.death_by)} ${renderDirectMatchupsSection(matchups)}
` : `

Sin estadisticas ampliadas disponibles.

` }
`; } function renderPlayerStatChip(label, value) { return `
${escapeHtml(label)} ${escapeHtml(value)}
`; } function renderNamedCountSection(title, items) { if (!hasNamedCounts(items)) { return `
${escapeHtml(title)}

No disponible

`; } return `
${escapeHtml(title)}
    ${items .map((stat) => { const name = stat.name || stat.label || "Sin nombre"; const count = stat.count ?? stat.total ?? 0; return `
  1. ${escapeHtml(name)}${escapeHtml(formatNumber(count))}
  2. `; }) .join("")}
`; } function renderDirectMatchupsSection(matchups) { if (!matchups.length) { return `
Duelo directo

No disponible

`; } return `
Duelo directo
Rival Abatidos Muertes Balance
${matchups .map( (matchup) => `
${escapeHtml(matchup.name)} ${escapeHtml(formatNumber(matchup.kills))} ${escapeHtml(formatNumber(matchup.deaths))} ${escapeHtml(formatSignedNumber(matchup.balance))}
`, ) .join("")}
`; } 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 = ` Ver en Scoreboard `; 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))) { return formatDecimal(player.kd_ratio, 2); } const kills = Number(player.kills); const deaths = Number(player.deaths); if (!Number.isFinite(kills) || !Number.isFinite(deaths)) { return "No disponible"; } return deaths > 0 ? formatDecimal(kills / deaths, 2) : formatDecimal(kills, 2); } function formatKpm(kills, durationSeconds) { const parsedKills = Number(kills); const parsedDurationSeconds = Number(durationSeconds); if ( !Number.isFinite(parsedKills) || !Number.isFinite(parsedDurationSeconds) || parsedDurationSeconds <= 0 ) { return formatDecimal(0, 2); } return formatDecimal(parsedKills / (parsedDurationSeconds / 60), 2); } 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 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("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); }