From 335fb80d6531128211914a43333378b9fc76d254 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Wed, 17 Jun 2026 09:59:57 +0200 Subject: [PATCH] Fix current match killfeed visible limit and polling stability --- ...feed-layout-for-normalized-weapon-icons.md | 35 ++- frontend/assets/js/partida-actual.js | 203 ++++++++++++++++-- 2 files changed, 216 insertions(+), 22 deletions(-) diff --git a/ai/tasks/done/TASK-261-improve-current-match-killfeed-layout-for-normalized-weapon-icons.md b/ai/tasks/done/TASK-261-improve-current-match-killfeed-layout-for-normalized-weapon-icons.md index 4ce43a8..d70edac 100644 --- a/ai/tasks/done/TASK-261-improve-current-match-killfeed-layout-for-normalized-weapon-icons.md +++ b/ai/tasks/done/TASK-261-improve-current-match-killfeed-layout-for-normalized-weapon-icons.md @@ -29,6 +29,7 @@ Preservar la identidad HLL Vietnam: militar, Vietnam, tactica y sobria. No tocar 4. Limitar en frontend el numero de eventos renderizados para mostrar solo filas completas. 5. Mantener dos columnas solo con ancho suficiente y pasar a una columna en ancho medio/mobile. 6. Validar visualmente las vistas de partida actual para `comunidad-hispana-01` y `comunidad-hispana-02`. +7. Restaurar ventana reciente reemplazada para el killfeed, con deduplicacion defensiva y sin cache/TTL adicional. ## Files to Read First @@ -45,9 +46,9 @@ Preservar la identidad HLL Vietnam: militar, Vietnam, tactica y sobria. No tocar - `frontend/assets/css/historico.css` - `frontend/assets/js/partida-actual.js` -- `ai/tasks/done/TASK-261-improve-current-match-killfeed-layout-for-normalized-weapon-icons.md` +- `ai/tasks/in-progress/TASK-261-improve-current-match-killfeed-layout-for-normalized-weapon-icons.md` -`frontend/assets/js/partida-actual.js` se modifica para limitar los eventos visibles por breakpoint. +`frontend/assets/js/partida-actual.js` se modifica para limitar los eventos visibles por breakpoint y para reemplazar la ventana reciente del feed cuando llega una respuesta valida. ## Constraints @@ -81,10 +82,12 @@ Diagnostico corregido: - La correccion anterior quitaba cortes, pero convertia `#current-match-feed-list.current-match-killfeed` en nodo scrolleable con `max-height: 620px`, `overflow-y: auto`, `scroll-padding-bottom` y `scrollbar-gutter`. - El comportamiento correcto es renderizar solo las bajas recientes que caben completas y ocultar eventos mas antiguos cuando hay demasiados. - Analisis de parpadeo posterior: - - El comportamiento anterior correcto del feed era pedir la lista completa con `/api/current-match/kills?...&limit=18` en cada ciclo y renderizar sobre el estado acumulado. - - El cambio que altera el flujo no viene del limite visual de TASK-261; viene de `d558ac8 feat: refine current match live feed`, que anadio `since_event_id` y limpiaba `state.byId` cuando el endpoint respondia `scope: no-current-match-events`. + - El comportamiento anterior correcto del feed era pedir la lista completa con `/api/current-match/kills?...&limit=18` en cada ciclo y reemplazar la ventana reciente renderizable. + - `d558ac8 feat: refine current match live feed` introdujo `since_event_id` y limpiaba `state.byId` cuando el endpoint respondia `scope: no-current-match-events`. - En produccion se observo el ciclo exacto: request sin cursor devuelve 18 bajas; request siguiente con `since_event_id` devuelve `no-current-match-events` con `items: 0`; el JS borra `state.byId`; el siguiente ciclo vuelve a pedir sin cursor y reaparece la lista. - Por eso el feed alternaba entre lista visible y estado vacio. + - La version posterior ya elimino `since_event_id`, pero seguia usando `state.byId` como acumulador local indefinido por `event_id`. + - Ese acumulador podia conservar eventos viejos o incompletos, duplicar bajas si `event_id` cambiaba, y mezclar metadata `unknown` con metadata correcta para el mismo jugador. Cambios aplicados: @@ -102,13 +105,33 @@ Cambios aplicados: - Si hay mas eventos almacenados que visibles, el estado muestra: `Mostrando las últimas N bajas detectadas.` - Se restauro el patron de peticion anterior eliminando `since_event_id` del request de killfeed. - Se ajusto la limpieza de `no-current-match-events`: solo limpia cuando no hay bajas validas en memoria. Una respuesta vacia transitoria ya no borra una lista valida. +- Reapertura actual: + - `loadKillFeed()` mantiene la request sin `since_event_id`. + - Cuando `data.items` trae eventos validos, `renderKillFeed()` construye una ventana nueva desde esa respuesta y reemplaza `state.byId` con la ventana deduplicada. + - La deduplicacion usa `event_id` cuando existe y clave semantica defensiva con `server_time`, `event_timestamp`, `killer_name`, `victim_name` y `weapon` para detectar duplicados con distinto id. + - Si dos filas representan la misma baja, se conserva la version con mejor metadata: equipos conocidos sobre `unknown` y nombres reales sobre placeholders. + - Si `data.items` llega vacio, el feed visible se conserva salvo que no hubiera eventos o exista cambio claro de contexto por servidor/partida/mapa. - No se anadio cache/TTL porque el problema era el cursor incremental borrando estado valido, no la ausencia de una politica de cache. - Se mantienen las mejoras previas: filas de `min-height: 74px`, columna de arma `128px`, mobile `104px`, ellipsis en nombres/labels y una columna desde `max-width: 1280px`. Validacion ejecutada: - `node --check frontend/assets/js/partida-actual.js`: OK. -- Browser plugin: no se pudo usar porque la herramienta requerida `node_repl js` no esta expuesta; se uso Playwright local como fallback. +- Reapertura actual: + - `node --check frontend/assets/js/partida-actual.js`: OK. + - Fixture Node/DOM simulado: OK para respuesta con 20 kills -> 12/6/5, respuesta vacia posterior preservada, metadata `unknown` reemplazada por equipos conocidos, duplicado semantico con distinto `event_id` renderizado una sola vez, y ventana nueva reemplazando la anterior. + - Fixture Node/DOM simulado para `comunidad-hispana-01` y `comunidad-hispana-02`: OK sin `TypeError` sincronico con `#current-match-history` ausente. + - Chrome headless local con frontend real y APIs simuladas: OK para desktop 1440px con 12 filas, 1280px con 6 filas y 480px con 5 filas; en los tres casos `clippedRows: 0`, `overflow-y: visible` y `scrollHeight == clientHeight`. + - Chrome headless local con secuencia real de polling simulada: OK para 20 kills -> respuesta vacia -> metadata mejorada -> duplicado semantico -> kills nuevas; no bajo a 0 filas tras vacio, conservo metadata de equipo conocido, renderizo un solo duplicado y actualizo a la ventana nueva. + - Chrome headless local contra backend publico de produccion durante varios ciclos: OK para `comunidad-hispana-01` y `comunidad-hispana-02`; 10 requests de killfeed por servidor, muestras estables `[0,0,0,0,0,0,0,0]`, sin alternancia lista/vacio, sin duplicados semanticos y sin `TypeError`. + - Nota de produccion: durante esta validacion publica ambos servidores devolvieron 0 bajas recientes, por lo que duplicados y mejora de metadata se validaron con fixture visual en navegador real. + - Browser plugin: no se pudo usar porque `node_repl js` no esta expuesto por tool discovery. + - Playwright Python local: no usable por `ModuleNotFoundError: No module named 'greenlet._greenlet'`. + - Playwright .NET CLI local: no usable porque no encuentra proyecto Playwright en el repositorio. + - Endpoints publicos con `GET`: `200` en `/api/current-match`, `/api/current-match/kills?limit=18` y `/api/current-match/players` para `comunidad-hispana-01` y `comunidad-hispana-02`. + - `HEAD` en esos endpoints responde `501`; se descarta para esta validacion porque el contrato real es `GET`. + - `git diff --name-only` revisado: mis cambios de TASK-261 son el JS y el movimiento/documentacion de la task; persisten cambios previos no relacionados en `ai/system-metrics.md`, assets de clans/mapas/weapons, TASK-204/TASK-242 y `tmp/`. +- Validacion previa: Browser plugin no se pudo usar porque la herramienta requerida `node_repl js` no estaba expuesta; en esa ejecucion se uso Playwright local como fallback. - Fixture larga de 20 kills interceptando las APIs de la pagina real: - desktop `1440px`: renderiza 12 kills, 2 columnas, `overflow-y: visible`, `scrollHeight == clientHeight`, sin scrollbar interno, `clippedRows: 0`, copy `Mostrando las últimas 12 bajas detectadas.` - medio `1100px`: renderiza 6 kills, 1 columna, `overflow-y: visible`, `scrollHeight == clientHeight`, sin scrollbar interno, `clippedRows: 0`, copy `Mostrando las últimas 6 bajas detectadas.` @@ -135,7 +158,7 @@ Validacion ejecutada: Alcance: -- TASK-261 queda en `ai/tasks/done` para cierre y commit aislado. +- TASK-261 queda cerrada en `ai/tasks/done` tras validacion visual/manual de estabilidad. - No se tocaron assets fisicos, SVGs, PNGs, `frontend/assets/img/weapons/`, backend, scheduler, RCON, TeamKills, Elo/MMR, mapas, clans, brands, configuracion de servidores, `27001`, `ai/system-metrics.md` ni TASK-204. ## Change Budget diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js index c4e1d4c..6817632 100644 --- a/frontend/assets/js/partida-actual.js +++ b/frontend/assets/js/partida-actual.js @@ -191,6 +191,7 @@ function initializeKillFeed(nodes) { return { byId: new Map(), latestEventId: "", + contextSignature: "", visibleSignature: "", visibleCount: 0, }; @@ -221,22 +222,51 @@ function renderKillFeed(data, nodes, state) { if (!nodes.feedList || !nodes.feedState) { return; } - const incoming = Array.isArray(data.items) ? data.items : []; - const hasVisibleEvents = state.byId.size > 0; - if (data.scope === "no-current-match-events" && !hasVisibleEvents) { - state.byId.clear(); - state.latestEventId = ""; + const incoming = Array.isArray(data.items) + ? data.items.filter(isValidKillFeedEvent) + : []; + const contextSignature = getKillFeedContextSignature(data); + const hasStoredEvents = state.byId.size > 0; + const contextChanged = + Boolean(contextSignature) && + Boolean(state.contextSignature) && + contextSignature !== state.contextSignature; + if (incoming.length > 0) { + const events = buildKillFeedWindow(incoming) + .sort(compareKillFeedEvents) + .slice(-CURRENT_MATCH_KILL_FEED_LIMIT); + replaceKillFeedWindow(state, events, contextSignature); + renderKillFeedEvents({ + data, + events, + nodes, + state, + }); + return; + } + if (!hasStoredEvents || contextChanged) { + replaceKillFeedWindow(state, [], contextSignature || state.contextSignature); + renderKillFeedEvents({ + data, + events: [], + nodes, + state, + }); + return; } - 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; + replaceKillFeedWindow(state, events, state.contextSignature); + renderKillFeedEvents({ + data: { ...data, scope: "preserved-recent-window" }, + events, + nodes, + state, + }); +} + +function renderKillFeedEvents({ data, events, nodes, state }) { if (events.length === 0) { nodes.feedList.innerHTML = ""; state.visibleSignature = ""; @@ -246,9 +276,7 @@ function renderKillFeed(data, nodes, state) { } const visibleLimit = getKillFeedVisibleLimit(); const visualEvents = events.slice(-visibleLimit); - const visibleSignature = `${visibleLimit}:${visualEvents - .map((event) => event.event_id) - .join("|")}`; + const visibleSignature = getKillFeedVisibleSignature(visualEvents, visibleLimit); if (visibleSignature !== state.visibleSignature) { nodes.feedList.innerHTML = renderKillFeedColumns(visualEvents); state.visibleSignature = visibleSignature; @@ -262,6 +290,146 @@ function renderKillFeed(data, nodes, state) { nodes.feedState.classList.remove("historical-state--error"); } +function replaceKillFeedWindow(state, events, contextSignature) { + state.byId = new Map( + events.map((event) => [getKillFeedWindowKey(event), event]), + ); + state.latestEventId = events[events.length - 1]?.event_id || ""; + state.contextSignature = contextSignature || state.contextSignature; +} + +function buildKillFeedWindow(events) { + const deduped = []; + const indexByEventId = new Map(); + const indexBySemanticKey = new Map(); + events.forEach((event) => { + const eventIdKey = getKillFeedEventIdKey(event); + const semanticKey = getKillFeedSemanticKey(event); + const existingIndex = + (eventIdKey ? indexByEventId.get(eventIdKey) : undefined) ?? + (semanticKey ? indexBySemanticKey.get(semanticKey) : undefined); + if (existingIndex !== undefined) { + deduped[existingIndex] = pickBetterKillFeedEvent( + deduped[existingIndex], + event, + ); + if (eventIdKey) { + indexByEventId.set(eventIdKey, existingIndex); + } + if (semanticKey) { + indexBySemanticKey.set(semanticKey, existingIndex); + } + return; + } + const nextIndex = deduped.length; + deduped.push(event); + if (eventIdKey) { + indexByEventId.set(eventIdKey, nextIndex); + } + if (semanticKey) { + indexBySemanticKey.set(semanticKey, nextIndex); + } + }); + return deduped; +} + +function pickBetterKillFeedEvent(current, candidate) { + if (!current) { + return candidate; + } + return getKillFeedMetadataScore(candidate) > getKillFeedMetadataScore(current) + ? candidate + : current; +} + +function getKillFeedMetadataScore(event) { + return [ + isKnownKillFeedTeam(event?.killer_team), + isKnownKillFeedTeam(event?.victim_team), + isAvailableKillFeedName(event?.killer_name, "Jugador no disponible"), + isAvailableKillFeedName(event?.victim_name, "Objetivo no disponible"), + Boolean(String(event?.weapon || "").trim()), + Boolean(String(event?.event_timestamp || "").trim()), + Number.isFinite(Number(event?.server_time)), + Boolean(String(event?.event_id || "").trim()), + ].filter(Boolean).length; +} + +function isValidKillFeedEvent(event) { + return Boolean( + event && + typeof event === "object" && + (getKillFeedEventIdKey(event) || getKillFeedSemanticKey(event)), + ); +} + +function getKillFeedWindowKey(event) { + return getKillFeedSemanticKey(event) || getKillFeedEventIdKey(event); +} + +function getKillFeedEventIdKey(event) { + const eventId = String(event?.event_id || "").trim(); + return eventId ? `event:${eventId}` : ""; +} + +function getKillFeedSemanticKey(event) { + const parts = [ + event?.server_time, + event?.event_timestamp, + event?.killer_name, + event?.victim_name, + event?.weapon, + ].map((value) => normalizeKillFeedDedupeValue(value)); + return parts.every(Boolean) ? `semantic:${parts.join("|")}` : ""; +} + +function getKillFeedVisibleSignature(events, visibleLimit) { + return `${visibleLimit}:${events + .map((event) => + [ + getKillFeedWindowKey(event), + normalizeKillFeedDedupeValue(event?.killer_name), + normalizeKillFeedDedupeValue(event?.victim_name), + normalizeKillFeedDedupeValue(event?.killer_team), + normalizeKillFeedDedupeValue(event?.victim_team), + normalizeKillFeedDedupeValue(event?.weapon), + event?.is_teamkill ? "1" : "0", + ].join(":"), + ) + .join("|")}`; +} + +function getKillFeedContextSignature(data) { + return [ + data?.server_slug, + data?.match_id, + data?.map, + data?.map_id, + data?.layer_id, + data?.map_pretty_name, + ] + .map((value) => normalizeKillFeedDedupeValue(value)) + .filter(Boolean) + .join("|"); +} + +function normalizeKillFeedDedupeValue(value) { + return String(value ?? "").trim().toLowerCase(); +} + +function isKnownKillFeedTeam(value) { + return getPlayerTeamDisplay(value).key !== "unknown"; +} + +function isAvailableKillFeedName(value, fallback) { + const normalized = normalizeLookupText(value); + return Boolean( + normalized && + normalized !== normalizeLookupText(fallback) && + normalized !== normalizeLookupText("No disponible"), + ); +} + function getKillFeedVisibleLimit() { if (window.matchMedia("(max-width: 480px)").matches) { return CURRENT_MATCH_KILL_FEED_VISIBLE_LIMIT_MOBILE; @@ -712,7 +880,10 @@ function formatDuration(value) { } function formatKillFeedCoverage(scope, visibleCount = 0, totalCount = 0) { - if (visibleCount > 0 && totalCount > visibleCount) { + if ( + visibleCount > 0 && + (totalCount > visibleCount || scope === "preserved-recent-window") + ) { return `Mostrando las últimas ${visibleCount} bajas detectadas.`; } if (scope === "open-admin-log-match-window") {