Fix current match killfeed visible limit and polling stability

This commit is contained in:
devRaGonSa
2026-06-17 09:59:57 +02:00
parent f1c52249ef
commit 335fb80d65
2 changed files with 216 additions and 22 deletions

View File

@@ -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. 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. 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`. 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 ## 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/css/historico.css`
- `frontend/assets/js/partida-actual.js` - `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 ## 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`. - 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. - El comportamiento correcto es renderizar solo las bajas recientes que caben completas y ocultar eventos mas antiguos cuando hay demasiados.
- Analisis de parpadeo posterior: - 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 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.
- 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`. - `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. - 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. - 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: Cambios aplicados:
@@ -102,13 +105,33 @@ Cambios aplicados:
- Si hay mas eventos almacenados que visibles, el estado muestra: `Mostrando las últimas N bajas detectadas.` - 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 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. - 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. - 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`. - 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: Validacion ejecutada:
- `node --check frontend/assets/js/partida-actual.js`: OK. - `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: - 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.` - 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.` - 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: 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. - 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 ## Change Budget

View File

@@ -191,6 +191,7 @@ function initializeKillFeed(nodes) {
return { return {
byId: new Map(), byId: new Map(),
latestEventId: "", latestEventId: "",
contextSignature: "",
visibleSignature: "", visibleSignature: "",
visibleCount: 0, visibleCount: 0,
}; };
@@ -221,22 +222,51 @@ function renderKillFeed(data, nodes, state) {
if (!nodes.feedList || !nodes.feedState) { if (!nodes.feedList || !nodes.feedState) {
return; return;
} }
const incoming = Array.isArray(data.items) ? data.items : []; const incoming = Array.isArray(data.items)
const hasVisibleEvents = state.byId.size > 0; ? data.items.filter(isValidKillFeedEvent)
if (data.scope === "no-current-match-events" && !hasVisibleEvents) { : [];
state.byId.clear(); const contextSignature = getKillFeedContextSignature(data);
state.latestEventId = ""; const hasStoredEvents = state.byId.size > 0;
} const contextChanged =
incoming.forEach((event) => { Boolean(contextSignature) &&
if (event?.event_id) { Boolean(state.contextSignature) &&
state.byId.set(event.event_id, event); contextSignature !== state.contextSignature;
} if (incoming.length > 0) {
const events = buildKillFeedWindow(incoming)
.sort(compareKillFeedEvents)
.slice(-CURRENT_MATCH_KILL_FEED_LIMIT);
replaceKillFeedWindow(state, events, contextSignature);
renderKillFeedEvents({
data,
events,
nodes,
state,
}); });
return;
}
if (!hasStoredEvents || contextChanged) {
replaceKillFeedWindow(state, [], contextSignature || state.contextSignature);
renderKillFeedEvents({
data,
events: [],
nodes,
state,
});
return;
}
const events = [...state.byId.values()] const events = [...state.byId.values()]
.sort(compareKillFeedEvents) .sort(compareKillFeedEvents)
.slice(-CURRENT_MATCH_KILL_FEED_LIMIT); .slice(-CURRENT_MATCH_KILL_FEED_LIMIT);
state.byId = new Map(events.map((event) => [event.event_id, event])); replaceKillFeedWindow(state, events, state.contextSignature);
state.latestEventId = events[events.length - 1]?.event_id || state.latestEventId; renderKillFeedEvents({
data: { ...data, scope: "preserved-recent-window" },
events,
nodes,
state,
});
}
function renderKillFeedEvents({ data, events, nodes, state }) {
if (events.length === 0) { if (events.length === 0) {
nodes.feedList.innerHTML = ""; nodes.feedList.innerHTML = "";
state.visibleSignature = ""; state.visibleSignature = "";
@@ -246,9 +276,7 @@ function renderKillFeed(data, nodes, state) {
} }
const visibleLimit = getKillFeedVisibleLimit(); const visibleLimit = getKillFeedVisibleLimit();
const visualEvents = events.slice(-visibleLimit); const visualEvents = events.slice(-visibleLimit);
const visibleSignature = `${visibleLimit}:${visualEvents const visibleSignature = getKillFeedVisibleSignature(visualEvents, visibleLimit);
.map((event) => event.event_id)
.join("|")}`;
if (visibleSignature !== state.visibleSignature) { if (visibleSignature !== state.visibleSignature) {
nodes.feedList.innerHTML = renderKillFeedColumns(visualEvents); nodes.feedList.innerHTML = renderKillFeedColumns(visualEvents);
state.visibleSignature = visibleSignature; state.visibleSignature = visibleSignature;
@@ -262,6 +290,146 @@ function renderKillFeed(data, nodes, state) {
nodes.feedState.classList.remove("historical-state--error"); 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() { function getKillFeedVisibleLimit() {
if (window.matchMedia("(max-width: 480px)").matches) { if (window.matchMedia("(max-width: 480px)").matches) {
return CURRENT_MATCH_KILL_FEED_VISIBLE_LIMIT_MOBILE; return CURRENT_MATCH_KILL_FEED_VISIBLE_LIMIT_MOBILE;
@@ -712,7 +880,10 @@ function formatDuration(value) {
} }
function formatKillFeedCoverage(scope, visibleCount = 0, totalCount = 0) { 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.`; return `Mostrando las últimas ${visibleCount} bajas detectadas.`;
} }
if (scope === "open-admin-log-match-window") { if (scope === "open-admin-log-match-window") {