From 28395b14b48cb1dfa29f95d212f265c7d60ede7f Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Wed, 20 May 2026 18:20:43 +0200 Subject: [PATCH 01/39] fix: align recent match cards --- .../done/TASK-139-align-recent-match-cards.md | 82 +++++++ frontend/assets/css/historico.css | 59 ++--- frontend/assets/js/historico-recent-live.js | 203 ++++++++++-------- frontend/assets/js/historico.js | 26 +-- 4 files changed, 219 insertions(+), 151 deletions(-) create mode 100644 ai/tasks/done/TASK-139-align-recent-match-cards.md diff --git a/ai/tasks/done/TASK-139-align-recent-match-cards.md b/ai/tasks/done/TASK-139-align-recent-match-cards.md new file mode 100644 index 0000000..4750a68 --- /dev/null +++ b/ai/tasks/done/TASK-139-align-recent-match-cards.md @@ -0,0 +1,82 @@ +--- +id: TASK-139 +title: Align recent match cards +status: done +type: frontend +team: Frontend Senior +supporting_teams: [Experto en interfaz] +roadmap_item: foundation +priority: medium +--- + +# TASK-139 - Align recent match cards + +## Goal + +Make the recent match cards in `historico.html` use consistent metadata columns and a right-aligned action area across static and dynamic renderers. + +## Context + +The recent matches list should stay compact and clean while preserving the HLL Vietnam dark tactical theme. It must not show raw match IDs, internal status/source/debug text, or the public scoreboard action in the list. The public scoreboard link remains available on the match detail page. + +## Steps + +1. Inspect the listed files first. +2. Normalize the static and dynamic recent card markup. +3. Use CSS grid for deterministic metadata/action alignment. +4. Validate with syntax checks and the requested Docker frontend checks. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/frontend-senior.md` +- `frontend/historico.html` +- `frontend/assets/js/historico.js` +- `frontend/assets/js/historico-recent-live.js` +- `frontend/assets/css/historico.css` + +## Expected Files to Modify + +- `frontend/assets/js/historico.js` +- `frontend/assets/js/historico-recent-live.js` +- `frontend/assets/css/historico.css` +- `ai/tasks/done/TASK-139-align-recent-match-cards.md` + +## Constraints + +- Keep the change minimal. +- Preserve the HLL Vietnam visual identity. +- Do not introduce frameworks or backend changes. +- Do not alter detail page scoreboard link behavior. +- Do not remove `match_url` support. + +## Validation + +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-partida.js` +- `docker compose build --no-cache frontend` +- `docker compose up -d frontend` +- Manual verification on `http://localhost:8080/historico.html?nocache=alignment` + +## Outcome + +Implemented a shared clean recent-card structure for the static snapshot renderer and dynamic live renderer. The recent list now shows only map title, Servidor, Cierre, Jugadores, Marcador and right-aligned actions. The public scoreboard action keeps its `match_url` link in markup with a recent-list-only hidden class, while the detail page scoreboard action remains visible. + +Validation completed: + +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-partida.js` +- `docker compose build --no-cache frontend` +- `docker compose up -d frontend` +- Browser verification on `http://localhost:8080/historico.html?nocache=alignment` +- Browser verification on the supplied `historico-partida.html` detail URL + +Manual verification found 13 recent cards, 13 visible `Ver detalles` links, zero visible `Ver partida` links in the recent list, no forbidden internal/debug text in the recent list, 0px column spread across all desktop metadata columns, and a visible public scoreboard link on the detail page. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index ddd380d..bd33098 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -776,58 +776,31 @@ } .historical-match-meta--clean { - grid-template-columns: minmax(180px, 1.3fr) minmax(120px, 0.8fr) minmax(110px, 0.7fr) minmax(110px, 0.7fr) auto; + grid-template-columns: + minmax(220px, 1.4fr) + minmax(130px, 0.75fr) + minmax(110px, 0.65fr) + minmax(110px, 0.65fr) + 260px; align-items: end; } +.historical-match-meta--clean > article { + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + .historical-match-card__actions-cell { display: flex; align-items: end; justify-content: flex-end; - min-width: 250px; -} - -.historical-match-card__actions-cell .historical-match-card__actions { - align-items: center; - justify-content: flex-end; -} - -@media (max-width: 920px) { - .historical-match-meta--clean { - grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); - } - - .historical-match-card__actions-cell { - justify-content: flex-start; - min-width: 0; - } - - .historical-match-card__actions-cell .historical-match-card__actions { - justify-content: flex-start; - } -} - -.historical-match-card--clean { - gap: 12px; -} - -.historical-match-card__top--clean { - display: block; -} - -.historical-match-meta--clean { - grid-template-columns: minmax(180px, 1.3fr) minmax(120px, 0.8fr) minmax(110px, 0.7fr) minmax(110px, 0.7fr) auto; - align-items: end; -} - -.historical-match-card__actions-cell { - display: flex; - align-items: end; - justify-content: flex-end; - min-width: 250px; + min-width: 260px; } .historical-match-card__actions-cell .historical-match-card__actions { + gap: 10px; align-items: center; justify-content: flex-end; } @@ -849,6 +822,6 @@ /* Hide public scoreboard action only in the recent matches list. The internal detail page can still show its own scoreboard button. */ -#recent-matches-list .historical-match-card__link[href^="https://scoreboard.comunidadhll.es"] { +#recent-matches-list .historical-match-card__link--scoreboard { display: none; } diff --git a/frontend/assets/js/historico-recent-live.js b/frontend/assets/js/historico-recent-live.js index fe3c1cb..2180a86 100644 --- a/frontend/assets/js/historico-recent-live.js +++ b/frontend/assets/js/historico-recent-live.js @@ -1,4 +1,4 @@ -(() => { +(() => { const RECENT_MATCHES_ENDPOINT = "/api/historical/recent-matches"; const REFRESH_DELAYS_MS = [150, 1000, 3000]; @@ -25,6 +25,7 @@ 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; } @@ -35,17 +36,18 @@ try { const response = await fetch( - `${backendBaseUrl}${RECENT_MATCHES_ENDPOINT}?server=${encodeURIComponent( - serverSlug, - )}&limit=10`, + `${backendBaseUrl}${RECENT_MATCHES_ENDPOINT}?server=${encodeURIComponent(serverSlug)}&limit=20`, { cache: "no-store" }, ); + if (!response.ok) { throw new Error(`HTTP ${response.status}`); } + const payload = await response.json(); const data = payload?.data || {}; const items = Array.isArray(data.items) ? data.items : []; + if (!items.length) { setDynamicState(stateNode, "No hay partidas recientes disponibles para este alcance."); listNode.innerHTML = ""; @@ -55,16 +57,72 @@ listNode.innerHTML = items.map((item) => renderDynamicRecentMatchCard(item)).join(""); stateNode.hidden = true; + if (noteNode) { - noteNode.textContent = "Lista dinamica de partidas registradas por el modelo RCON reciente."; + noteNode.textContent = "Lista dinámica de partidas registradas."; } - metaNode.textContent = buildDynamicRecentMeta(data, items); + + metaNode.textContent = buildDynamicRecentMeta(items); } catch (error) { - setDynamicState(stateNode, "No se pudieron cargar las partidas recientes dinamicas.", true); - metaNode.textContent = "Error al leer las partidas recientes dinamicas."; + setDynamicState(stateNode, "No se pudieron cargar las partidas recientes dinámicas.", true); + metaNode.textContent = "Error al leer las partidas recientes dinámicas."; } } + 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 externalUrl = normalizeDynamicExternalMatchUrl(item?.match_url); + + const actionLinks = [ + `${escapeDynamicHtml(formatDynamicResultLabel(item?.result))}`, + detailUrl + ? `Ver detalles` + : "", + externalUrl + ? `Ver partida` + : "", + ].join(""); + + return ` +
+
+

${escapeDynamicHtml(mapName)}

+
+ +
+
+

Servidor

+ ${escapeDynamicHtml(serverName)} +
+ +
+

Cierre

+ ${escapeDynamicHtml(formatDynamicTimestamp(closedAt))} +
+ +
+

Jugadores

+ ${escapeDynamicHtml(formatDynamicNumber(item?.player_count))} +
+ +
+

Marcador

+ ${escapeDynamicHtml(formatDynamicScore(item?.result))} +
+ +
+
+ ${actionLinks} +
+
+
+
+ `; + } + function readServerFromUrl() { return new URLSearchParams(window.location.search).get("server") || "all-servers"; } @@ -77,92 +135,9 @@ return "all-servers"; } - 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 externalUrl = normalizeDynamicExternalMatchUrl(item?.match_url); - - const actions = [ - `${escapeDynamicHtml(formatDynamicResultLabel(item?.result))}`, - detailUrl - ? `Ver detalles` - : "", - externalUrl - ? `Ver partida` - : "", - ].join(""); - - return ` -
-
-

${escapeDynamicHtml(mapName)}

-
-
-
-

Servidor

- ${escapeDynamicHtml(serverName)} -
-
-

Cierre

- ${escapeDynamicHtml(formatDynamicTimestamp(closedAt))} -
-
-

Jugadores

- ${escapeDynamicHtml(formatDynamicNumber(item?.player_count))} -
-
-

Marcador

- ${escapeDynamicHtml(formatDynamicScore(item?.result))} -
-
-
- ${actions} -
-
-
-
- `; - } - - 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 buildDynamicRecentMeta(data, items) { + function buildDynamicRecentMeta(items) { const newest = items[0]?.closed_at || items[0]?.ended_at || items[0]?.started_at; - const source = data.selected_source || data.source || "rcon"; - const captureText = newest ? `Actualizado: ${formatDynamicTimestamp(newest)}` : "Actualizado recientemente"; - return `${captureText} | Fuente: ${source}`; + return newest ? `Actualizado: ${formatDynamicTimestamp(newest)}` : "Actualizado recientemente"; } function setDynamicState(node, message, isError = false) { @@ -175,10 +150,12 @@ 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", @@ -196,12 +173,52 @@ 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("&", "&") diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index 8aa2c5c..0ce8853 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -805,6 +805,7 @@ function renderRecentMatchCard(item) { const matchUrl = normalizeExternalMatchUrl(item.match_url); const detailUrl = buildInternalMatchDetailUrl(item); const actionLinks = [ + `${escapeHtml(formatMatchResult(item.result))}`, detailUrl ? ` -
-
-

Partida ${escapeHtml(item.match_id || "sin id")}

-

${escapeHtml(mapName)}

-
-
- ${escapeHtml(formatMatchResult(item.result))} - ${actionLinks} -
+
+
+

${escapeHtml(mapName)}

-
+ +

Servidor

${escapeHtml(item.server?.name || "Servidor no disponible")} @@ -857,9 +852,10 @@ function renderRecentMatchCard(item) {

Marcador

${escapeHtml(formatScore(item.result))}
-
-

Estado

- ${escapeHtml(formatRecentMatchStatus(item))} +
+
+ ${actionLinks} +
From 74f73d61a63a36163438afe9f6301c1c14790eac Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Wed, 20 May 2026 18:27:25 +0200 Subject: [PATCH 02/39] fix: restore historical runner elo imports --- .gitignore | 1 + ...SK-140-fix-historical-runner-elo-import.md | 103 ++++++++++++++++++ backend/app/elo_mmr_models.py | 3 + 3 files changed, 107 insertions(+) create mode 100644 ai/tasks/done/TASK-140-fix-historical-runner-elo-import.md diff --git a/.gitignore b/.gitignore index 284dd33..31b24dd 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ ai/reports/*.md # Local backend runtime data backend/runtime/ backend/data/*.sqlite3 +backend/data/*.writer.lock !backend/data/.gitkeep backend/data/snapshots/** !backend/data/snapshots/.gitkeep diff --git a/ai/tasks/done/TASK-140-fix-historical-runner-elo-import.md b/ai/tasks/done/TASK-140-fix-historical-runner-elo-import.md new file mode 100644 index 0000000..ebf44f8 --- /dev/null +++ b/ai/tasks/done/TASK-140-fix-historical-runner-elo-import.md @@ -0,0 +1,103 @@ +--- +id: TASK-140 +title: Restore historical runner Elo imports +status: done +type: backend +team: Backend Senior +supporting_teams: [Arquitecto Python] +roadmap_item: foundation +priority: high +--- + +# TASK-140 - Restore historical runner Elo imports + +## Goal + +Restore `historical-runner` startup by fixing the Elo/MMR model import contract used by `app.elo_mmr_engine`. + +## Context + +The advanced `historical-runner` service runs `python -m app.historical_runner --hourly` and intentionally imports `rebuild_elo_mmr_models`. The engine imports scoring constants from `app.elo_mmr_models`, but that module no longer exports every constant required by the engine. + +## Steps + +1. Inspect the listed files first. +2. Restore the missing Elo/MMR model/config constants in the correct module. +3. Keep the change narrow and avoid disabling the runner or Elo/MMR silently. +4. Validate the backend, RCON data pipeline, integration tests, advanced Compose services and relevant HTTP endpoints. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `backend/app/historical_runner.py` +- `backend/app/historical_ingestion.py` +- `backend/app/elo_mmr_engine.py` +- `backend/app/elo_mmr_models.py` +- `backend/app/elo_mmr_storage.py` + +## Expected Files to Modify + +- `backend/app/elo_mmr_models.py` +- `.gitignore` +- `ai/tasks/done/TASK-140-fix-historical-runner-elo-import.md` + +## Constraints + +- Do not remove or disable `historical-runner`. +- Do not disable Elo/MMR silently. +- Do not modify frontend files. +- Do not reintroduce Comunidad Hispana #03. +- Do not commit runtime database files. +- Preserve RCON ingestion, AdminLog materialization, recent matches, match detail and scoreboard candidate backfill. + +## Validation + +- `python -m compileall backend/app` +- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend historical-runner rcon-historical-worker` +- `docker compose --profile advanced ps` +- `docker compose logs --tail=100 historical-runner` +- `Invoke-WebRequest "http://localhost:8000/health" | Select-Object -ExpandProperty Content` +- `Invoke-WebRequest "http://localhost:8000/api/historical/recent-matches?server=all-servers&limit=20" | Select-Object -ExpandProperty Content` + +## Outcome + +Implemented the import-contract fix by restoring the missing Elo/MMR model constants exported from `backend/app/elo_mmr_models.py`: + +- `ELO_K_FACTOR` +- `MIN_VALID_PLAYER_PARTICIPATION_SECONDS` +- `MIN_VALID_PLAYER_PARTICIPATION_RATIO` + +The constants live with the rest of the Elo/MMR model thresholds, preserving the existing `app.elo_mmr_engine` import boundary and avoiding any silent disabling of the runner or Elo/MMR. `.gitignore` now also ignores `backend/data/*.writer.lock` because advanced Compose validation creates the shared SQLite writer lock as runtime state. + +Validation completed: + +- `python -m compileall backend/app` +- Direct imports of `app.historical_runner`, `app.historical_ingestion` and `app.elo_mmr_engine` +- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend historical-runner rcon-historical-worker` +- `docker compose --profile advanced ps` +- `docker compose logs --tail=100 historical-runner` +- `Invoke-WebRequest "http://localhost:8000/health" | Select-Object -ExpandProperty Content` +- `Invoke-WebRequest "http://localhost:8000/api/historical/recent-matches?server=all-servers&limit=20" | Select-Object -ExpandProperty Content` +- `Invoke-WebRequest "http://localhost:8080/historico.html?nocache=runner" | Select-Object -ExpandProperty StatusCode` + +Manual verification: + +- `historical-runner` stayed `Up` in the advanced profile. +- `rcon-historical-worker` stayed `Up`. +- `historical-runner` logs showed `historical-refresh-loop-started` and no `ImportError`. +- `/health` returned `status: "ok"`. +- `/api/historical/recent-matches` returned RCON-backed recent match data. +- `historico.html` returned HTTP 200. +- No frontend files were modified. +- No runtime database files were staged or committed. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. diff --git a/backend/app/elo_mmr_models.py b/backend/app/elo_mmr_models.py index 652e27e..e07d2e3 100644 --- a/backend/app/elo_mmr_models.py +++ b/backend/app/elo_mmr_models.py @@ -14,8 +14,11 @@ ACCURACY_APPROXIMATE = "approximate" ACCURACY_PARTIAL = "partial" DEFAULT_BASE_MMR = 1000.0 +ELO_K_FACTOR = 60.0 MIN_VALID_MATCH_DURATION_SECONDS = 900 MIN_VALID_MATCH_PLAYERS = 20 +MIN_VALID_PLAYER_PARTICIPATION_SECONDS = 900 +MIN_VALID_PLAYER_PARTICIPATION_RATIO = 0.45 FULL_QUALITY_PLAYER_COUNT = 70 FULL_QUALITY_DURATION_SECONDS = 3600 MONTHLY_MIN_VALID_MATCHES = 5 From 16258effb72037c404771d79ca2d26bec1ff800c Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Wed, 20 May 2026 19:16:12 +0200 Subject: [PATCH 03/39] fix: keep recent match actions inline --- ...SK-141-keep-recent-match-actions-inline.md | 86 +++++++++++++++++++ frontend/assets/css/historico.css | 23 ++++- 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 ai/tasks/done/TASK-141-keep-recent-match-actions-inline.md diff --git a/ai/tasks/done/TASK-141-keep-recent-match-actions-inline.md b/ai/tasks/done/TASK-141-keep-recent-match-actions-inline.md new file mode 100644 index 0000000..1075095 --- /dev/null +++ b/ai/tasks/done/TASK-141-keep-recent-match-actions-inline.md @@ -0,0 +1,86 @@ +--- +id: TASK-141 +title: Keep recent match actions inline +status: done +type: frontend +team: Frontend Senior +supporting_teams: [Experto en interfaz] +roadmap_item: foundation +priority: medium +--- + +# TASK-141 - Keep recent match actions inline + +## Goal + +Keep the result chip and `Ver detalles` action on the same horizontal row in recent match cards. + +## Context + +The recent matches cards already use the clean structure, but the right-side action controls can wrap vertically on desktop. The fix should preserve the current recent card structure, keep public scoreboard links hidden only in the recent list, and avoid affecting `historico-partida.html`. + +## Steps + +1. Inspect the listed files first. +2. Apply a scoped CSS fix for recent match card actions. +3. Validate syntax, Docker frontend build/start and rendered behavior. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/frontend-senior.md` +- `docs/decisions.md` +- `frontend/historico.html` +- `frontend/assets/css/historico.css` +- `frontend/assets/js/historico.js` +- `frontend/assets/js/historico-recent-live.js` + +## Expected Files to Modify + +- `frontend/assets/css/historico.css` +- `ai/tasks/done/TASK-141-keep-recent-match-actions-inline.md` + +## Constraints + +- Do not modify backend. +- Do not affect the detail page scoreboard link. +- Do not show `Ver partida` in recent cards. +- Do not reintroduce raw match ids, Estado, source or RCON debug text. +- Keep the dark HLL Vietnam theme. + +## Validation + +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico-partida.js` +- `docker compose build --no-cache frontend` +- `docker compose up -d frontend` +- Browser verification on `http://localhost:8080/historico.html?nocache=actions-inline` +- Browser verification of the supplied detail-page scoreboard behavior + +## Outcome + +Implemented a scoped CSS fix for recent match actions: + +- Widened the clean recent-card action grid track to fit result chip plus detail link on desktop. +- Forced `#recent-matches-list .historical-match-card__actions` to use row-direction, no-wrap flex layout on desktop. +- Kept mobile/tablet wrapping available under the existing responsive breakpoint. +- Kept the public scoreboard link hidden only under `#recent-matches-list`. + +Validation completed: + +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico-partida.js` +- `docker compose build --no-cache frontend` +- `docker compose up -d frontend` +- Browser verification on `http://localhost:8080/historico.html?nocache=actions-inline` +- Browser verification on the supplied `historico-partida.html` detail URL + +Manual verification found 10 recent cards, 10 visible `Ver detalles` links, zero visible `Ver partida` links in the recent list, no forbidden internal/debug text, all result chips inline with `Ver detalles`, all result states present, no framework overlay, no relevant console issues, and a visible scoreboard link on the detail page. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index bd33098..98e3157 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -781,7 +781,7 @@ minmax(130px, 0.75fr) minmax(110px, 0.65fr) minmax(110px, 0.65fr) - 260px; + 340px; align-items: end; } @@ -796,7 +796,7 @@ display: flex; align-items: end; justify-content: flex-end; - min-width: 260px; + min-width: 340px; } .historical-match-card__actions-cell .historical-match-card__actions { @@ -805,6 +805,20 @@ justify-content: flex-end; } +#recent-matches-list .historical-match-card__actions { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +#recent-matches-list .historical-match-card__result, +#recent-matches-list .historical-match-card__link { + flex: 0 0 auto; +} + @media (max-width: 920px) { .historical-match-meta--clean { grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); @@ -818,6 +832,11 @@ .historical-match-card__actions-cell .historical-match-card__actions { justify-content: flex-start; } + + #recent-matches-list .historical-match-card__actions { + flex-wrap: wrap; + justify-content: flex-start; + } } /* Hide public scoreboard action only in the recent matches list. From 73498a80df19c403f88a372bd34240db442bff33 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Wed, 20 May 2026 20:50:07 +0200 Subject: [PATCH 04/39] fix: distinguish player teams and correct recent player counts --- ...2-player-team-visuals-and-recent-counts.md | 99 +++++++++++++++++++ backend/app/rcon_admin_log_materialization.py | 26 +++-- backend/app/rcon_historical_read_model.py | 17 +++- .../css/historico-scoreboard-detail.css | 61 ++++++++++++ frontend/assets/js/historico-partida.js | 27 +++-- 5 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 ai/tasks/done/TASK-142-player-team-visuals-and-recent-counts.md diff --git a/ai/tasks/done/TASK-142-player-team-visuals-and-recent-counts.md b/ai/tasks/done/TASK-142-player-team-visuals-and-recent-counts.md new file mode 100644 index 0000000..11f3505 --- /dev/null +++ b/ai/tasks/done/TASK-142-player-team-visuals-and-recent-counts.md @@ -0,0 +1,99 @@ +--- +id: TASK-142 +title: Player team visuals and recent counts +status: done +type: frontend +team: Frontend Senior +supporting_teams: [Backend Senior, Experto en interfaz] +roadmap_item: foundation +priority: high +--- + +# TASK-142 - Player team visuals and recent counts + +## Goal + +Fix historical UI/data consistency by visually distinguishing player teams in internal match detail tables and exposing meaningful recent-match player counts for RCON materialized matches. + +## Context + +Recent match cards can show `Jugadores = 0` even when the corresponding internal detail page has materialized player rows. The detail player table also needs an additive visual distinction for Allies/Aliados and Axis/Eje rows while preserving all existing stats and detail-page behavior. + +## Steps + +1. Inspect the listed files first. +2. Fix the backend RCON recent-match read model to expose a non-zero materialized player count when player stats exist. +3. Add team-specific visual styling to all internal match detail player rows or team cells. +4. Validate backend, frontend syntax, RCON pipeline, integration scripts, advanced Compose services and rendered UI behavior. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `ai/orchestrator/frontend-senior.md` +- `backend/app/rcon_historical_read_model.py` +- `backend/app/rcon_admin_log_materialization.py` +- `backend/app/rcon_admin_log_storage.py` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/css/historico-scoreboard-detail.css` +- `frontend/assets/css/historico.css` + +## Expected Files to Modify + +- `backend/app/rcon_historical_read_model.py` +- `backend/app/rcon_admin_log_materialization.py` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/css/historico-scoreboard-detail.css` +- `ai/tasks/done/TASK-142-player-team-visuals-and-recent-counts.md` + +## Constraints + +- Do not modify public scoreboard behavior. +- Do not modify frontend recent-card layout beyond consuming corrected backend data. +- Do not reintroduce raw match id, Estado, Resultado confirmado, Fuente, RCON/debug text, timeline/events, confidence/source/base, Elo/MVP blocks or Comunidad Hispana #03. +- Do not commit runtime DB files. + +## Validation + +- `python -m compileall backend/app` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico-partida.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend historical-runner rcon-historical-worker` +- `docker compose --profile advanced ps` +- `Invoke-WebRequest "http://localhost:8000/health" | Select-Object -ExpandProperty Content` +- `Invoke-WebRequest "http://localhost:8000/api/historical/recent-matches?server=all-servers&limit=20" | Select-Object -ExpandProperty Content` +- Browser verification on `http://localhost:8080/historico.html?nocache=player-counts` +- Browser verification on an internal match detail page with materialized players + +## Outcome + +Implemented. Materialized RCON recent-match rows now include player-stat counts and the RCON historical read model exposes those counts as `player_count` for recent cards and detail payloads. The internal match detail player table now renders localized team badges and team-specific row accents for Aliados, Eje and No disponible while preserving the existing stats columns and detail-page scoreboard link. + +Validation passed: + +- `python -m compileall backend/app` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico-partida.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend historical-runner rcon-historical-worker` +- `docker compose --profile advanced ps` +- `Invoke-WebRequest "http://localhost:8000/health" | Select-Object -ExpandProperty Content` +- `Invoke-WebRequest "http://localhost:8000/api/historical/recent-matches?server=all-servers&limit=20" | Select-Object -ExpandProperty Content` +- Browser verification on `http://localhost:8080/historico.html?nocache=player-counts` +- Browser verification on `historico-partida.html` for `comunidad-hispana-02:1779178461:1779183861:carentanwarfare` + +Notes: + +- The RCON pipeline test reports existing SQLite `ResourceWarning` messages from its test harness, but both unittest suites return `OK` and the script reports validation passed. +- The browser plugin was not exposed by tool discovery in this session, so rendered UI verification used local Chrome/Selenium fallback. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. diff --git a/backend/app/rcon_admin_log_materialization.py b/backend/app/rcon_admin_log_materialization.py index 691dae3..4a0d291 100644 --- a/backend/app/rcon_admin_log_materialization.py +++ b/backend/app/rcon_admin_log_materialization.py @@ -149,21 +149,35 @@ def list_materialized_rcon_matches( clauses: list[str] = [] params: list[object] = [] if target_key: - clauses.append("(target_key = ? OR external_server_id = ?)") + clauses.append("(m.target_key = ? OR m.external_server_id = ?)") params.extend([target_key, target_key]) if only_ended: - clauses.append("source_basis = ?") + clauses.append("m.source_basis = ?") params.append(MATCH_RESULT_SOURCE) where = "WHERE " + " AND ".join(clauses) if clauses else "" params.append(limit) with closing(connect_sqlite_readonly(resolved_path)) as connection: rows = connection.execute( f""" - SELECT * - FROM rcon_materialized_matches + SELECT + m.*, + ( + SELECT COUNT(*) + FROM rcon_match_player_stats AS stats + WHERE stats.target_key = m.target_key + AND stats.match_key = m.match_key + ) AS materialized_player_count, + ( + SELECT COUNT(DISTINCT TRIM(stats.player_name)) + FROM rcon_match_player_stats AS stats + WHERE stats.target_key = m.target_key + AND stats.match_key = m.match_key + AND TRIM(COALESCE(stats.player_name, '')) != '' + ) AS materialized_distinct_player_count + FROM rcon_materialized_matches AS m {where} - ORDER BY COALESCE(ended_at, started_at) DESC, - COALESCE(ended_server_time, started_server_time) DESC + ORDER BY COALESCE(m.ended_at, m.started_at) DESC, + COALESCE(m.ended_server_time, m.started_server_time) DESC LIMIT ? """, params, diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index 697014b..cf8a85f 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -208,6 +208,7 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object server_slug = item.get("external_server_id") or item.get("target_key") timestamps = _build_materialized_timestamp_payload(item) correlation_window = _build_materialized_scoreboard_correlation_window(item, timestamps) + player_count = _resolve_materialized_player_count(item) return { "server": { "slug": item.get("target_key"), @@ -232,7 +233,7 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object "winner": item.get("winner"), }, "winner": item.get("winner"), - "player_count": None, + "player_count": player_count, "peak_players": None, "sample_count": None, "duration_seconds": _calculate_match_duration_seconds(item), @@ -273,6 +274,7 @@ def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str ) for row in materialized["players"] ] + player_count = len(players) if players else recent_item.get("player_count") return { **recent_item, "match_id": match["match_key"], @@ -280,6 +282,7 @@ def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str "winner": match.get("winner"), "confidence": match.get("confidence_mode"), "source_basis": match.get("source_basis"), + "player_count": player_count, "players": players, "timeline": { "event_counts": materialized.get("timeline", []), @@ -287,6 +290,18 @@ def _build_materialized_detail_item(materialized: dict[str, object]) -> dict[str } +def _resolve_materialized_player_count(item: dict[str, object]) -> int | None: + for key in ( + "player_count", + "materialized_player_count", + "materialized_distinct_player_count", + ): + value = _coerce_optional_int(item.get(key)) + if value is not None and value > 0: + return value + return None + + def _build_player_row( row: dict[str, object], *, diff --git a/frontend/assets/css/historico-scoreboard-detail.css b/frontend/assets/css/historico-scoreboard-detail.css index 0015199..273edb2 100644 --- a/frontend/assets/css/historico-scoreboard-detail.css +++ b/frontend/assets/css/historico-scoreboard-detail.css @@ -10,6 +10,67 @@ display: none !important; } +.historical-table--players tbody tr.historical-player-row--allies td:first-child { + box-shadow: inset 4px 0 0 rgba(104, 162, 214, 0.82); +} + +.historical-table--players tbody tr.historical-player-row--axis td:first-child { + box-shadow: inset 4px 0 0 rgba(190, 82, 64, 0.82); +} + +.historical-table--players tbody tr.historical-player-row--unknown td:first-child { + box-shadow: inset 4px 0 0 rgba(159, 168, 141, 0.5); +} + +.historical-table--players tbody tr.historical-player-row--allies { + background: linear-gradient(90deg, rgba(74, 126, 178, 0.14), transparent 42%); +} + +.historical-table--players tbody tr.historical-player-row--axis { + background: linear-gradient(90deg, rgba(156, 66, 49, 0.16), transparent 42%); +} + +.historical-table--players tbody tr.historical-player-row--unknown { + background: linear-gradient(90deg, rgba(159, 168, 141, 0.08), transparent 42%); +} + +.historical-player-team-cell { + white-space: nowrap; +} + +.historical-player-team-badge { + display: inline-flex; + align-items: center; + min-width: 96px; + justify-content: center; + padding: 5px 10px; + border: 1px solid rgba(159, 168, 141, 0.24); + border-radius: 999px; + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.08em; + line-height: 1; + text-transform: uppercase; +} + +.historical-player-team-badge--allies { + border-color: rgba(118, 175, 229, 0.46); + background: rgba(61, 109, 163, 0.2); + color: #c8e1ff; +} + +.historical-player-team-badge--axis { + border-color: rgba(213, 105, 83, 0.48); + background: rgba(129, 45, 35, 0.22); + color: #f2beb2; +} + +.historical-player-team-badge--unknown { + border-color: rgba(159, 168, 141, 0.28); + background: rgba(159, 168, 141, 0.1); + color: var(--muted); +} + .historical-scoreboard-layout { display: grid; gap: 18px; diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index 3d05a88..fc479e2 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -218,10 +218,15 @@ function renderPlayerSection(item, nodes) { } function renderPlayerRow(player) { + const team = getTeamSideDisplay(player.team || player.team_side); return ` - + ${escapeHtml(player.player_name || player.name || "Jugador no identificado")} - ${escapeHtml(formatTeamSide(player.team || player.team_side))} + + + ${escapeHtml(team.label)} + + ${escapeHtml(formatOptionalNumber(player.kills))} ${escapeHtml(formatOptionalNumber(player.deaths))} ${escapeHtml(formatOptionalNumber(player.teamkills))} @@ -338,14 +343,20 @@ function normalizeLookupText(value) { } function formatTeamSide(value) { - const normalized = String(value || "").toLowerCase(); - if (normalized === "allies" || normalized === "allied") { - return "Aliados"; + 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") { - return "Eje"; + if (normalized === "axis" || normalized === "eje") { + return { key: "axis", label: "Eje" }; } - return value || "No disponible"; + return { key: "unknown", label: "No disponible" }; } function formatGameMode(value) { From b534f0c7c35fb797981b9b3a825f0965c18f1540 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Wed, 20 May 2026 21:18:44 +0200 Subject: [PATCH 05/39] feat: add player hover stats panel --- ...TASK-143-player-table-hover-stats-panel.md | 80 ++++++ .../css/historico-scoreboard-detail.css | 253 ++++++++++++++++++ frontend/assets/js/historico-partida.js | 244 ++++++++++++++++- frontend/historico-partida.html | 4 +- 4 files changed, 571 insertions(+), 10 deletions(-) create mode 100644 ai/tasks/done/TASK-143-player-table-hover-stats-panel.md diff --git a/ai/tasks/done/TASK-143-player-table-hover-stats-panel.md b/ai/tasks/done/TASK-143-player-table-hover-stats-panel.md new file mode 100644 index 0000000..c2aea64 --- /dev/null +++ b/ai/tasks/done/TASK-143-player-table-hover-stats-panel.md @@ -0,0 +1,80 @@ +--- +id: TASK-143 +title: Player table hover stats panel +status: done +type: frontend +team: Frontend Senior +supporting_teams: [Experto en interfaz] +roadmap_item: foundation +priority: high +--- + +# TASK-143 - Player table hover stats panel + +## Goal + +Make the internal historical match detail player table more compact by moving expanded per-player statistics into an accessible hover/focus/click details panel. + +## Context + +The detail page player table currently includes detailed columns for weapons, most killed and death by. The desired scoreboard-like UX keeps the table focused on player/team/core combat metrics and reveals expanded statistics per row. + +## Steps + +1. Inspect the current match detail renderer and scoreboard detail CSS. +2. Reduce the visible table columns to Jugador, Equipo, K, D, TK, K/D and KPM. +3. Add accessible row hover/focus/click expanded panels with weapons, most killed, death by and direct matchups. +4. Preserve team styling, scoreboard link behavior and forbidden hidden sections. +5. Validate syntax, frontend build/container and rendered behavior. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/frontend-senior.md` +- `frontend/historico-partida.html` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/css/historico-scoreboard-detail.css` +- `frontend/assets/css/historico.css` + +## Expected Files to Modify + +- `frontend/historico-partida.html` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/css/historico-scoreboard-detail.css` +- `ai/tasks/done/TASK-143-player-table-hover-stats-panel.md` + +## Constraints + +- Do not modify backend unless absolutely necessary. +- Do not modify recent match card layout. +- Do not reintroduce timeline/events/confidence/source/base or Elo/MVP blocks. +- Preserve Aliados/Eje/No disponible visual distinction. + +## Validation + +- `node --check frontend/assets/js/historico-partida.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `docker compose build --no-cache frontend` +- `docker compose up -d frontend` +- Browser verification on `historico-partida.html` +- Browser smoke verification on `historico.html` + +## Outcome + +Implemented. The match detail player table now shows only Jugador, Equipo, K, D, TK, K/D and KPM. Expanded player statistics are rendered in per-player detail panels that open on row hover, keyboard focus/Enter and touch/click via an accessible info control. The panel includes the player/team summary, weapons, most killed, death by and direct matchup balance derived from most_killed and death_by. + +Validation passed: + +- `node --check frontend/assets/js/historico-partida.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `docker compose build --no-cache frontend` +- `docker compose up -d frontend` +- Browser verification on `historico-partida.html` for `comunidad-hispana-02:1779178461:1779183861:carentanwarfare` +- Browser smoke verification on `historico.html?nocache=player-table-hover` + +Notes: + +- Browser plugin runtime tools were not exposed by tool discovery in this session, so rendered validation used local Chrome/Selenium fallback. diff --git a/frontend/assets/css/historico-scoreboard-detail.css b/frontend/assets/css/historico-scoreboard-detail.css index 273edb2..f1148ce 100644 --- a/frontend/assets/css/historico-scoreboard-detail.css +++ b/frontend/assets/css/historico-scoreboard-detail.css @@ -34,6 +34,244 @@ background: linear-gradient(90deg, rgba(159, 168, 141, 0.08), transparent 42%); } +.historical-table--players { + min-width: 760px; +} + +.historical-player-row { + cursor: pointer; + outline: 0; +} + +.historical-player-row:hover td, +.historical-player-row:focus td, +.historical-player-row:focus-within td, +.historical-player-row.is-expanded td { + background: rgba(210, 182, 118, 0.06); +} + +.historical-player-row:focus-visible td, +.historical-player-row__details-button:focus-visible { + outline: 2px solid rgba(210, 182, 118, 0.78); + outline-offset: -2px; +} + +.historical-player-row__details-button { + display: flex; + width: 100%; + min-width: 220px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.historical-player-row__details-button span:first-child { + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.historical-player-row__details-button span:last-child { + flex: 0 0 auto; + display: inline-flex; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + border: 1px solid rgba(210, 182, 118, 0.28); + border-radius: 999px; + color: var(--accent-strong); + font-size: 0.72rem; + font-weight: 900; + line-height: 1; + text-transform: uppercase; +} + +.historical-player-detail-row { + display: none; +} + +.historical-player-row:hover + .historical-player-detail-row, +.historical-player-row:focus + .historical-player-detail-row, +.historical-player-row:focus-within + .historical-player-detail-row, +.historical-player-detail-row:hover, +.historical-player-detail-row:focus-within, +.historical-player-detail-row.is-open { + display: table-row; +} + +.historical-player-detail-row td { + padding: 0 12px 16px; + background: rgba(7, 9, 7, 0.76); + border-bottom-color: rgba(210, 182, 118, 0.16); +} + +.historical-player-stats-panel { + display: grid; + gap: 16px; + padding: 16px; + border: 1px solid rgba(210, 182, 118, 0.18); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(19, 24, 17, 0.96), rgba(10, 13, 10, 0.98)), + rgba(10, 13, 10, 0.96); + box-shadow: 0 18px 34px rgba(0, 0, 0, 0.28); +} + +.historical-player-stats-panel__header { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(0, 2fr); + gap: 16px; + align-items: start; +} + +.historical-player-stats-panel__header p, +.historical-player-stats-panel__section p, +.historical-player-stats-panel__empty { + margin: 0; + color: var(--muted); +} + +.historical-player-stats-panel__header p { + margin-bottom: 5px; + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.historical-player-stats-panel__header h4, +.historical-player-stats-panel__section h5 { + margin: 0; + color: var(--text); +} + +.historical-player-stats-panel__header h4 { + font-size: 1.08rem; +} + +.historical-player-stats-panel__summary { + display: grid; + grid-template-columns: repeat(5, minmax(70px, 1fr)); + gap: 8px; +} + +.historical-player-stats-panel__summary article { + padding: 9px 10px; + border: 1px solid rgba(159, 168, 141, 0.14); + border-radius: 10px; + background: rgba(13, 17, 12, 0.68); +} + +.historical-player-stats-panel__summary span { + display: block; + margin-bottom: 4px; + color: var(--muted); + font-size: 0.64rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-player-stats-panel__summary strong { + color: var(--text); +} + +.historical-player-stats-panel__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.historical-player-stats-panel__section { + display: grid; + gap: 10px; + align-content: start; + padding: 13px; + border: 1px solid rgba(159, 168, 141, 0.14); + border-radius: 12px; + background: rgba(13, 17, 12, 0.52); +} + +.historical-player-stats-panel__section--wide { + grid-column: 1 / -1; +} + +.historical-player-stats-panel__section h5 { + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-player-stats-panel__section ol { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.historical-player-stats-panel__section li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: baseline; +} + +.historical-player-stats-panel__section li span { + overflow: hidden; + min-width: 0; + color: var(--text-soft); + text-overflow: ellipsis; + white-space: nowrap; +} + +.historical-player-stats-panel__section li strong { + color: var(--accent-strong); +} + +.historical-player-matchups { + display: grid; + gap: 0; + overflow-x: auto; +} + +.historical-player-matchups [role="row"] { + display: grid; + grid-template-columns: minmax(180px, 1fr) repeat(3, minmax(72px, auto)); + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid rgba(159, 168, 141, 0.1); +} + +.historical-player-matchups [role="row"]:last-child { + border-bottom: 0; +} + +.historical-player-matchups [role="columnheader"] { + color: var(--muted); + font-size: 0.66rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-player-matchups [role="cell"] { + color: var(--text-soft); +} + +.historical-player-matchups strong[role="cell"] { + color: var(--text); +} + .historical-player-team-cell { white-space: nowrap; } @@ -71,6 +309,21 @@ color: var(--muted); } +@media (max-width: 760px) { + .historical-player-stats-panel__header, + .historical-player-stats-panel__grid { + grid-template-columns: 1fr; + } + + .historical-player-stats-panel__summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .historical-player-matchups [role="row"] { + min-width: 520px; + } +} + .historical-scoreboard-layout { display: grid; gap: 18px; diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index fc479e2..5f67703 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -213,15 +213,39 @@ function renderPlayerSection(item, nodes) { nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`; nodes.playersState.hidden = true; - nodes.playersBody.innerHTML = players.map((player) => renderPlayerRow(player)).join(""); + nodes.playersBody.innerHTML = players + .map((player, index) => renderPlayerRows(player, item, index)) + .join(""); + bindPlayerDetailRows(nodes.playersBody); nodes.playersTableShell.hidden = false; } -function renderPlayerRow(player) { +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(player.player_name || player.name || "Jugador no identificado")} + + + + ${escapeHtml(team.label)} @@ -231,13 +255,194 @@ function renderPlayerRow(player) { ${escapeHtml(formatOptionalNumber(player.deaths))} ${escapeHtml(formatOptionalNumber(player.teamkills))} ${escapeHtml(formatKdRatio(player))} - ${escapeHtml(formatNamedCounts(player.top_weapons))} - ${escapeHtml(formatNamedCounts(player.most_killed))} - ${escapeHtml(formatNamedCounts(player.death_by))} + ${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) { @@ -411,6 +616,19 @@ function formatKdRatio(player) { 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"; @@ -425,6 +643,10 @@ function formatNamedCounts(items) { .join(" / "); } +function hasNamedCounts(items) { + return Array.isArray(items) && items.length > 0; +} + function formatNumber(value) { const parsedValue = Number(value); if (!Number.isFinite(parsedValue)) { @@ -433,6 +655,14 @@ function formatNumber(value) { 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)) { diff --git a/frontend/historico-partida.html b/frontend/historico-partida.html index 82bbb65..903c829 100644 --- a/frontend/historico-partida.html +++ b/frontend/historico-partida.html @@ -97,9 +97,7 @@ D TK K/D - Armas - Mas abatido - Muere por + KPM From b3691e5271186ed6c15e20cb481171233de54c0b Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Wed, 20 May 2026 21:48:24 +0200 Subject: [PATCH 06/39] fix: hide pending region and improve rcon freshness --- ...TASK-144-home-region-and-rcon-freshness.md | 113 ++++++++++++++++++ backend/app/historical_runner.py | 6 +- backend/app/rcon_admin_log_materialization.py | 48 ++++++++ backend/app/rcon_historical_worker.py | 23 +++- frontend/assets/js/main.js | 34 +++++- 5 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 ai/tasks/done/TASK-144-home-region-and-rcon-freshness.md diff --git a/ai/tasks/done/TASK-144-home-region-and-rcon-freshness.md b/ai/tasks/done/TASK-144-home-region-and-rcon-freshness.md new file mode 100644 index 0000000..bac2d57 --- /dev/null +++ b/ai/tasks/done/TASK-144-home-region-and-rcon-freshness.md @@ -0,0 +1,113 @@ +--- +id: TASK-144 +title: Home region cleanup and RCON freshness diagnostics +status: done +type: integration +team: Backend Senior +supporting_teams: [Frontend Senior] +roadmap_item: foundation +priority: high +--- + +# TASK-144 - Home region cleanup and RCON freshness diagnostics + +## Goal + +Hide placeholder region values on the home page server cards and improve RCON historical freshness diagnostics so stale recent matches are explainable from worker/runner logs. + +## Context + +The home page currently renders `Region pendiente` when a server region is missing. Recent RCON materialized matches can also appear stale without clear logs showing whether AdminLog events were seen, inserted, duplicated or materialized. + +## Steps + +1. Inspect home server-card rendering and RCON runner/worker/ingestion/materialization paths. +2. Hide placeholder/missing region quick facts while preserving map data. +3. Determine whether historical-runner refreshes RCON AdminLog materialization or whether rcon-historical-worker owns it. +4. Add minimal logging/summary output for AdminLog ingestion/materialization and latest materialized match freshness. +5. Validate frontend syntax, backend compile, pipeline scripts, Compose services, logs, manual commands and rendered pages. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `ai/orchestrator/frontend-senior.md` +- `frontend/assets/js/main.js` +- `backend/app/rcon_historical_worker.py` +- `backend/app/rcon_admin_log_ingestion.py` +- `backend/app/rcon_admin_log_materialization.py` +- `backend/app/historical_runner.py` +- `docker-compose.yml` + +## Expected Files to Modify + +- `frontend/assets/js/main.js` +- `backend/app/rcon_historical_worker.py` +- `backend/app/rcon_admin_log_ingestion.py` +- `backend/app/rcon_admin_log_materialization.py` +- `ai/tasks/done/TASK-144-home-region-and-rcon-freshness.md` + +## Constraints + +- Do not expose secrets. +- Do not reintroduce Comunidad Hispana #03. +- Do not change recent-card visual design. +- Do not commit runtime DB files. +- Preserve manual RCON commands. + +## Validation + +- `python -m compileall backend/app` +- `node --check frontend/assets/js/main.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico-partida.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend historical-runner rcon-historical-worker` +- `docker compose --profile advanced ps` +- `docker compose logs --tail=150 historical-runner` +- `docker compose logs --tail=150 rcon-historical-worker` +- `docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 360` +- `docker compose exec backend python -m app.rcon_admin_log_materialization` +- `Invoke-WebRequest "http://localhost:8000/health" | Select-Object -ExpandProperty Content` +- `Invoke-WebRequest "http://localhost:8000/api/historical/recent-matches?server=all-servers&limit=20" | Select-Object -ExpandProperty Content` +- Browser verification on `index.html?nocache=region` +- Browser verification on `historico.html?nocache=freshness` + +## Outcome + +Implemented. Home server cards now omit the Region quick fact when the region is missing or a placeholder such as `Region pendiente`, while preserving the Mapa quick fact. RCON capture now runs AdminLog materialization after AdminLog ingestion and emits freshness diagnostics in the worker result, including event counters, materialized match counters and latest materialized/AdminLog match-end timestamps per configured server. + +Findings: + +- `historical-runner` already calls the RCON capture path when historical data source is RCON, but the capture path previously ingested AdminLog entries without materializing them into recent matches. +- `rcon-historical-worker` owns the 10-minute capture loop and now materializes after ingestion, so recent matches can advance without requiring a manual materialization command. +- The live diagnostics show new recent data after the previously stale `17:38` item. Recent matches now include `comunidad-hispana-01:1779299747:1779305147:carentanwarfare` closed at `2026-05-20T19:26:46.519Z` and `comunidad-hispana-02:1779296626:1779301626:carentanwarfare` closed at `2026-05-20T18:41:59.219Z`. + +Validation passed: + +- `python -m compileall backend/app` +- `node --check frontend/assets/js/main.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico-partida.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend historical-runner rcon-historical-worker` +- `docker compose --profile advanced ps` +- `docker compose logs --tail=150 historical-runner` +- `docker compose logs --tail=150 rcon-historical-worker` +- `docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 360` +- `docker compose exec backend python -m app.rcon_admin_log_materialization` +- `Invoke-WebRequest "http://localhost:8000/health" | Select-Object -ExpandProperty Content` +- `Invoke-WebRequest "http://localhost:8000/api/historical/recent-matches?server=all-servers&limit=20" | Select-Object -ExpandProperty Content` +- Browser verification on `http://localhost:8080/index.html?nocache=region` +- Browser verification on `http://localhost:8080/historico.html?nocache=freshness` + +Notes: + +- The RCON pipeline script reports existing SQLite `ResourceWarning` messages from its test harness, but the unittest suites return `OK` and the script reports validation passed. +- The integration script exits successfully, but the local runtime DB emits an existing `database disk image is malformed` traceback after the pass message. Runtime DB files were not modified for commit. +- Browser plugin runtime tools were not exposed by tool discovery in this session, so rendered validation used local Chrome/Selenium fallback. diff --git a/backend/app/historical_runner.py b/backend/app/historical_runner.py index d822286..49bd5d5 100644 --- a/backend/app/historical_runner.py +++ b/backend/app/historical_runner.py @@ -73,7 +73,7 @@ def run_periodic_historical_refresh( page_size=page_size, run_number=completed_runs, ) - print(json.dumps({"run": completed_runs, **payload}, indent=2)) + print(json.dumps({"run": completed_runs, **payload}, indent=2), flush=True) if max_runs is not None and completed_runs >= max_runs: break @@ -276,6 +276,10 @@ def _rcon_capture_has_new_useful_data(rcon_capture_result: dict[str, Any]) -> bo totals = rcon_capture_result.get("totals") if isinstance(totals, dict) and int(totals.get("samples_inserted") or 0) > 0: return True + if isinstance(totals, dict) and int(totals.get("admin_log_events_inserted") or 0) > 0: + return True + if isinstance(totals, dict) and int(totals.get("materialized_matches_inserted") or 0) > 0: + return True targets = rcon_capture_result.get("targets") if not isinstance(targets, list): return False diff --git a/backend/app/rcon_admin_log_materialization.py b/backend/app/rcon_admin_log_materialization.py index 4a0d291..a720b86 100644 --- a/backend/app/rcon_admin_log_materialization.py +++ b/backend/app/rcon_admin_log_materialization.py @@ -126,6 +126,7 @@ def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, obje except sqlite3.Error as error: errors.append(str(error)) + freshness = summarize_rcon_materialization_status(db_path=resolved_path) return { "matches_seen": matches_seen, "matches_materialized": matches_materialized, @@ -134,6 +135,8 @@ def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, obje "player_stats_materialized": player_stats_materialized, "player_stats_updated": player_stats_updated, "errors": errors, + "latest_materialized_matches": freshness["latest_materialized_matches"], + "latest_admin_log_match_end_events": freshness["latest_admin_log_match_end_events"], } @@ -275,11 +278,56 @@ def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dic ORDER BY target_key ASC, event_count DESC """ ).fetchall() + latest_matches = connection.execute( + """ + SELECT + target_key, + external_server_id, + match_key, + map_pretty_name, + COALESCE(ended_at, started_at) AS closed_at, + ended_at, + ended_server_time, + source_basis, + updated_at + FROM ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY target_key + ORDER BY COALESCE(ended_at, started_at) DESC, + COALESCE(ended_server_time, started_server_time) DESC, + updated_at DESC + ) AS row_number + FROM rcon_materialized_matches + WHERE source_basis = ? + ) + WHERE row_number = 1 + ORDER BY target_key ASC + """, + (MATCH_RESULT_SOURCE,), + ).fetchall() + latest_match_end_events = connection.execute( + """ + SELECT + target_key, + external_server_id, + MAX(event_timestamp) AS latest_event_timestamp, + MAX(server_time) AS latest_server_time, + COUNT(*) AS match_end_events + FROM rcon_admin_log_events + WHERE event_type = 'match_end' + GROUP BY target_key, external_server_id + ORDER BY target_key ASC + """ + ).fetchall() return { "materialized_matches": int(match_count or 0), "matches_with_player_stats": int(stats_match_count or 0), "server_time_ranges": [dict(row) for row in ranges], "event_counts": [dict(row) for row in event_counts], + "latest_materialized_matches": [dict(row) for row in latest_matches], + "latest_admin_log_match_end_events": [dict(row) for row in latest_match_end_events], } diff --git a/backend/app/rcon_historical_worker.py b/backend/app/rcon_historical_worker.py index 940c169..88206cb 100644 --- a/backend/app/rcon_historical_worker.py +++ b/backend/app/rcon_historical_worker.py @@ -16,6 +16,7 @@ from .config import ( get_rcon_request_timeout_seconds, ) from .rcon_admin_log_ingestion import ingest_rcon_admin_logs +from .rcon_admin_log_materialization import materialize_rcon_admin_log from .rcon_client import ( RconQueryError, build_rcon_target_key, @@ -44,6 +45,8 @@ class RconHistoricalCaptureStats: admin_log_events_inserted: int = 0 admin_log_duplicate_events: int = 0 admin_log_failed_targets: int = 0 + materialized_matches_inserted: int = 0 + materialized_matches_updated: int = 0 def run_rcon_historical_capture( @@ -66,6 +69,7 @@ def run_rcon_historical_capture_unlocked( """Capture one prospective RCON sample assuming the shared writer lock is already held.""" initialize_rcon_historical_storage() selected_targets = _select_targets(target_key) + selected_target_keys = {build_rcon_target_key(target) for target in selected_targets} admin_log_lookback_minutes = get_rcon_admin_log_lookback_minutes() captured_at = utc_now().isoformat().replace("+00:00", "Z") target_scope = target_key or "all-configured-rcon-targets" @@ -127,6 +131,14 @@ def run_rcon_historical_capture_unlocked( result=admin_log_result, ) + materialization_result = materialize_rcon_admin_log() + stats.materialized_matches_inserted = int( + materialization_result.get("matches_materialized") or 0 + ) + stats.materialized_matches_updated = int( + materialization_result.get("matches_updated") or 0 + ) + status = "success" if not errors else ("partial" if items else "failed") finalize_rcon_historical_capture_run( run_id, @@ -158,7 +170,12 @@ def run_rcon_historical_capture_unlocked( "targets": items, "errors": errors, "admin_log_errors": admin_log_errors, - "storage_status": list_rcon_historical_target_statuses(), + "materialization_result": materialization_result, + "storage_status": [ + status + for status in list_rcon_historical_target_statuses() + if status.get("target_key") in selected_target_keys + ], "totals": { "targets_seen": stats.targets_seen, "samples_inserted": stats.samples_inserted, @@ -168,6 +185,8 @@ def run_rcon_historical_capture_unlocked( "admin_log_events_inserted": stats.admin_log_events_inserted, "admin_log_duplicate_events": stats.admin_log_duplicate_events, "admin_log_failed_targets": stats.admin_log_failed_targets, + "materialized_matches_inserted": stats.materialized_matches_inserted, + "materialized_matches_updated": stats.materialized_matches_updated, }, } @@ -204,7 +223,7 @@ def run_periodic_rcon_historical_capture( retry_delay_seconds=retry_delay_seconds, target_key=target_key, ) - print(json.dumps({"run": completed_runs, **payload}, indent=2)) + print(json.dumps({"run": completed_runs, **payload}, indent=2), flush=True) if max_runs is not None and completed_runs >= max_runs: break time.sleep(interval_seconds) diff --git a/frontend/assets/js/main.js b/frontend/assets/js/main.js index 4f2b0a2..fad3285 100644 --- a/frontend/assets/js/main.js +++ b/frontend/assets/js/main.js @@ -266,16 +266,19 @@ function renderServerStatsCard(server) { server.status === "online" ? "server-state--online" : "server-state--offline"; const isRealSnapshot = isRealLiveSnapshot(server); const currentMap = server.current_map || "Sin mapa disponible"; - const region = server.region || "Region pendiente"; + 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 quickFacts = renderQuickFacts([ + const quickFactItems = [ { label: "Mapa", value: currentMap, valueClassName: "server-card__quickfact-value--map" }, - { label: "Region", value: region }, - ]); + ]; + if (region) { + quickFactItems.push({ label: "Region", value: region }); + } + const quickFacts = renderQuickFacts(quickFactItems); return `
@@ -299,6 +302,29 @@ 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", + "pending", + "unknown", + "desconocida", + "no disponible", + "n/a", + ]); + return placeholderValues.has(normalizedValue) ? "" : trimmedValue; +} + function renderServerAction(server) { const historyState = getServerHistoryState(server); if (!historyState.available) { From 239595052ecc44c9001bfd1573b17f31f7888bf8 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 21 May 2026 08:18:45 +0200 Subject: [PATCH 07/39] feat: migrate rcon historical storage to postgres --- ai/tasks/done/TASK-POSTGRES-RCON-PHASE-1.md | 141 +++ backend/.env.example | 1 + backend/README.md | 16 + backend/app/config.py | 14 + backend/app/postgres_rcon_storage.py | 986 ++++++++++++++++++ backend/app/rcon_admin_log_materialization.py | 150 ++- backend/app/rcon_admin_log_storage.py | 118 ++- backend/app/rcon_historical_storage.py | 86 +- backend/app/rcon_scoreboard_correlation.py | 16 +- backend/app/storage_diagnostics.py | 89 ++ backend/requirements.txt | 3 +- docker-compose.yml | 37 +- docs/decisions.md | 28 + .../css/historico-scoreboard-detail.css | 8 - frontend/assets/js/historico-partida.js | 33 +- frontend/assets/js/historico-recent-live.js | 5 - frontend/assets/js/historico.js | 13 - frontend/assets/js/main.js | 2 + .../run-historical-ui-regression-tests.ps1 | 4 +- 19 files changed, 1658 insertions(+), 92 deletions(-) create mode 100644 ai/tasks/done/TASK-POSTGRES-RCON-PHASE-1.md create mode 100644 backend/app/postgres_rcon_storage.py create mode 100644 backend/app/storage_diagnostics.py diff --git a/ai/tasks/done/TASK-POSTGRES-RCON-PHASE-1.md b/ai/tasks/done/TASK-POSTGRES-RCON-PHASE-1.md new file mode 100644 index 0000000..db50de0 --- /dev/null +++ b/ai/tasks/done/TASK-POSTGRES-RCON-PHASE-1.md @@ -0,0 +1,141 @@ +--- +id: TASK-POSTGRES-RCON-PHASE-1 +title: Migrate RCON historical persistence phase 1 +status: done +type: backend +team: Backend Senior +supporting_teams: + - Arquitecto de Base de Datos + - Frontend Senior +roadmap_item: foundation +priority: high +--- + +# TASK-POSTGRES-RCON-PHASE-1 - Migrate RCON historical persistence phase 1 + +## Goal + +Move the lock-prone RCON historical pipeline to PostgreSQL in Docker while +keeping local SQLite fallback and fixing the requested match-detail and server +card frontend regressions. + +## Context + +AdminLog ingestion and materialization currently share SQLite files with +multiple services and materialization can fail with `database is locked`. +PostgreSQL must become authoritative for RCON capture, AdminLog events, +materialized matches, player stats and the detail/recent read model in the +advanced Docker flow. Scoreboard historical persistence may remain on SQLite in +this phase when it is used only as fallback or correlation source. + +## Steps + +1. Inspect the storage, read-model and frontend files named in the request. +2. Add a deterministic PostgreSQL schema and route the phase-1 RCON storage + domains through it when `HLL_BACKEND_DATABASE_URL` is set. +3. Wire Compose diagnostics and required frontend fixes without expanding Elo, + backend scope outside RCON persistence, or Comunidad Hispana #03 targets. +4. Validate the requested commands, document what remains SQLite-backed, and + complete the task record. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `backend/app/config.py` +- `backend/app/rcon_admin_log_storage.py` +- `backend/app/rcon_admin_log_materialization.py` +- `frontend/assets/js/historico-partida.js` + +## Expected Files to Modify + +- `backend/app/config.py` +- `backend/app/postgres_rcon_storage.py` +- `backend/app/rcon_admin_log_storage.py` +- `backend/app/rcon_admin_log_materialization.py` +- `backend/app/rcon_historical_storage.py` +- `backend/app/rcon_scoreboard_correlation.py` +- `backend/app/storage_diagnostics.py` +- `backend/README.md` +- `backend/requirements.txt` +- `backend/.env.example` +- `docker-compose.yml` +- `frontend/assets/js/historico-partida.js` +- `frontend/assets/js/historico.js` +- `frontend/assets/js/historico-recent-live.js` +- `frontend/assets/js/main.js` +- `frontend/assets/css/historico-scoreboard-detail.css` +- `docs/decisions.md` +- `scripts/run-historical-ui-regression-tests.ps1` + +## Constraints + +- PostgreSQL is the Docker default for migrated RCON domains. +- SQLite remains only fallback or source material for domains not migrated in + this phase. +- Do not add SQLite lock workarounds as the main solution. +- Keep external scoreboard URLs on the trusted scoreboard allowlist. +- Do not modify unrelated files or add runtime database artifacts. + +## Validation + +- `python -m compileall backend/app` +- `python -m app.storage_diagnostics` +- `node --check frontend/assets/js/main.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico-partida.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- Requested Docker Compose and endpoint checks when local Docker/RCON + credentials permit them. + +## Outcome + +Implemented phase-1 PostgreSQL delegation for RCON capture samples/windows, +AdminLog events/profile snapshots, materialized RCON matches/player stats, and +safe scoreboard candidate caching. Docker Compose now configures PostgreSQL as +the RCON storage backend for `backend`, `historical-runner` and +`rcon-historical-worker`; local code paths with explicit SQLite paths or no +database URL retain SQLite fallback behavior. + +Frontend changes remove hover-driven player panel expansion in the internal +match detail table, keep the scoreboard link on the internal detail action area +only, and suppress pending-style region placeholders on server cards. + +Validation completed: + +- `python -m compileall backend/app` +- `python -m app.storage_diagnostics` from `backend/` +- `node --check frontend/assets/js/main.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `node --check frontend/assets/js/historico-partida.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced config --quiet` +- local fallback HTTP smoke for `/health`, + `/api/historical/recent-matches`, and the requested known match detail URL +- direct known-match detail payload check confirmed safe URL + `https://scoreboard.comunidadhll.es:5443/games/1562094` + +`scripts/run-rcon-data-pipeline-tests.ps1` completed parser/storage and +unittest work, then exited nonzero when its optional Docker smoke step could +not reach the Docker Desktop Linux engine. The requested `docker compose +--profile advanced up ...` and `docker compose --profile advanced ps` checks +failed for the same local engine-unavailable reason, so container diagnostics, +container RCON commands and Docker-backed HTTP probes remain to rerun when +Docker is available. The Browser plugin is listed but its required JavaScript +control tool was not exposed in this session, so rendered interaction QA remains +manual or follow-up Browser verification. + +Phase boundary documented in `docs/decisions.md` and diagnostics: live server +snapshot cache, public-scoreboard `historical_*` data/rankings, historical +snapshot files, player-event ledger and paused Elo/MMR storage remain SQLite or +file-backed in this phase. + +## Change Budget + +This phase intentionally exceeds the default change budget because PostgreSQL +wiring crosses schema initialization, Compose runtime, RCON storage domains, +diagnostics, docs, and the requested frontend regressions. Keep the migration +surface limited to the RCON path and leave remaining SQLite domains documented. diff --git a/backend/.env.example b/backend/.env.example index f4f5c3d..42cd572 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,6 +1,7 @@ HLL_BACKEND_HOST=0.0.0.0 HLL_BACKEND_PORT=8000 HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3 +HLL_BACKEND_DATABASE_URL=postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam HLL_BACKEND_ALLOWED_ORIGINS=http://127.0.0.1:8080,http://localhost:8080 HLL_BACKEND_REFRESH_INTERVAL_SECONDS=120 HLL_BACKEND_LIVE_DATA_SOURCE=rcon diff --git a/backend/README.md b/backend/README.md index adbcaac..bd1d7d1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -97,6 +97,7 @@ Variables especialmente relevantes para Docker y Compose: - `HLL_BACKEND_HOST` - `HLL_BACKEND_PORT` +- `HLL_BACKEND_DATABASE_URL` - `HLL_BACKEND_STORAGE_PATH` - `HLL_BACKEND_ALLOWED_ORIGINS` - `HLL_BACKEND_LIVE_DATA_SOURCE` @@ -132,6 +133,21 @@ Dentro del contenedor arranca por defecto con: - `HLL_BACKEND_PORT=8000` - `HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3` +Compose configura ademas `HLL_BACKEND_DATABASE_URL` para que PostgreSQL sea el +almacenamiento autoritativo de la fase 1 RCON: muestras/ventanas de captura, +AdminLog, snapshots de perfil y partidas/estadisticas materializadas. Sin esa +variable, la ejecucion local mantiene fallback SQLite para esos dominios. + +Diagnostico rapido del backend activo: + +```powershell +python -m app.storage_diagnostics +``` + +La salida lista el backend RCON activo, counts de las tablas migradas, la ultima +partida materializada por servidor y que superficies siguen temporalmente en +SQLite en esta fase. + Build local: ```powershell diff --git a/backend/app/config.py b/backend/app/config.py index a76ff36..9e668d7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -85,6 +85,20 @@ def get_storage_path() -> Path: return Path(configured_path) if configured_path else default_path +def get_database_url() -> str | None: + """Return the optional PostgreSQL URL for migrated backend storage domains.""" + configured_url = os.getenv("HLL_BACKEND_DATABASE_URL") + if configured_url is None: + return None + normalized_url = configured_url.strip() + return normalized_url or None + + +def use_postgres_rcon_storage(*, explicit_sqlite_path: Path | None = None) -> bool: + """Return whether phase-1 RCON storage should use PostgreSQL.""" + return explicit_sqlite_path is None and get_database_url() is not None + + def get_sqlite_writer_timeout_seconds() -> float: """Return the SQLite connection timeout shared by writer-capable storage layers.""" configured_value = os.getenv( diff --git a/backend/app/postgres_rcon_storage.py b/backend/app/postgres_rcon_storage.py new file mode 100644 index 0000000..220a84a --- /dev/null +++ b/backend/app/postgres_rcon_storage.py @@ -0,0 +1,986 @@ +"""PostgreSQL persistence for the phase-1 RCON historical pipeline.""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Mapping +from contextlib import contextmanager +from datetime import datetime, timezone +from typing import Any + +from .config import get_database_url +from .normalizers import normalize_map_name +from .rcon_client import load_rcon_targets + + +COMPETITIVE_WINDOW_GAP_SECONDS = 1800 +COMPETITIVE_MODE_PARTIAL = "partial" +COMPETITIVE_MODE_APPROXIMATE = "approximate" +COMPETITIVE_MODE_EXACT = "exact" + + +RCON_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS rcon_historical_targets ( + id BIGSERIAL PRIMARY KEY, + target_key TEXT NOT NULL UNIQUE, + external_server_id TEXT, + display_name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL, + region TEXT, + game_port INTEGER, + query_port INTEGER, + source_name TEXT NOT NULL, + last_configured_at TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rcon_historical_capture_runs ( + id BIGSERIAL PRIMARY KEY, + mode TEXT NOT NULL, + status TEXT NOT NULL, + target_scope TEXT, + started_at TEXT NOT NULL, + completed_at TEXT, + targets_seen INTEGER NOT NULL DEFAULT 0, + samples_inserted INTEGER NOT NULL DEFAULT 0, + duplicate_samples INTEGER NOT NULL DEFAULT 0, + failed_targets INTEGER NOT NULL DEFAULT 0, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rcon_historical_samples ( + id BIGSERIAL PRIMARY KEY, + target_id BIGINT NOT NULL REFERENCES rcon_historical_targets(id), + capture_run_id BIGINT REFERENCES rcon_historical_capture_runs(id), + captured_at TEXT NOT NULL, + source_kind TEXT NOT NULL, + status TEXT NOT NULL, + players INTEGER, + max_players INTEGER, + current_map TEXT, + normalized_payload_json TEXT NOT NULL, + raw_payload_json TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_id, captured_at) +); + +CREATE TABLE IF NOT EXISTS rcon_historical_checkpoints ( + target_id BIGINT PRIMARY KEY REFERENCES rcon_historical_targets(id), + last_successful_capture_at TEXT, + last_sample_at TEXT, + last_run_id BIGINT REFERENCES rcon_historical_capture_runs(id), + last_run_status TEXT, + last_error TEXT, + last_error_at TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rcon_historical_competitive_windows ( + id BIGSERIAL PRIMARY KEY, + target_id BIGINT NOT NULL REFERENCES rcon_historical_targets(id), + session_key TEXT NOT NULL UNIQUE, + source_kind TEXT NOT NULL, + map_name TEXT, + map_pretty_name TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + sample_count INTEGER NOT NULL DEFAULT 0, + total_players INTEGER NOT NULL DEFAULT 0, + peak_players INTEGER NOT NULL DEFAULT 0, + last_players INTEGER, + max_players INTEGER, + status TEXT NOT NULL, + confidence_mode TEXT NOT NULL, + capabilities_json TEXT NOT NULL, + latest_payload_json TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rcon_admin_log_events ( + id BIGSERIAL PRIMARY KEY, + target_key TEXT NOT NULL, + external_server_id TEXT, + event_timestamp TEXT, + server_time BIGINT, + relative_time TEXT, + event_type TEXT NOT NULL, + raw_message TEXT NOT NULL, + canonical_message TEXT NOT NULL, + parsed_payload_json TEXT NOT NULL, + raw_entry_json TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE NULLS NOT DISTINCT(target_key, server_time, canonical_message) +); + +CREATE TABLE IF NOT EXISTS rcon_player_profile_snapshots ( + id BIGSERIAL PRIMARY KEY, + target_key TEXT NOT NULL, + external_server_id TEXT, + player_id TEXT NOT NULL, + player_name TEXT NOT NULL, + source_server_time BIGINT NOT NULL, + event_timestamp TEXT, + first_seen TEXT, + sessions INTEGER, + matches_played INTEGER, + play_time TEXT, + total_kills INTEGER, + total_deaths INTEGER, + teamkills_done INTEGER, + teamkills_received INTEGER, + kd_ratio DOUBLE PRECISION, + favorite_weapons_json TEXT NOT NULL DEFAULT '{}', + victims_json TEXT NOT NULL DEFAULT '{}', + nemesis_json TEXT NOT NULL DEFAULT '{}', + averages_json TEXT NOT NULL DEFAULT '{}', + sanctions_json TEXT NOT NULL DEFAULT '{}', + raw_content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_key, player_id, source_server_time) +); + +CREATE TABLE IF NOT EXISTS rcon_materialized_matches ( + id BIGSERIAL PRIMARY KEY, + target_key TEXT NOT NULL, + external_server_id TEXT, + match_key TEXT NOT NULL, + map_name TEXT, + map_pretty_name TEXT, + game_mode TEXT, + started_server_time BIGINT, + ended_server_time BIGINT, + started_at TEXT, + ended_at TEXT, + allied_score INTEGER, + axis_score INTEGER, + winner TEXT, + confidence_mode TEXT NOT NULL, + source_basis TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_key, match_key) +); + +CREATE TABLE IF NOT EXISTS rcon_match_player_stats ( + id BIGSERIAL PRIMARY KEY, + target_key TEXT NOT NULL, + match_key TEXT NOT NULL, + player_id TEXT NOT NULL, + player_name TEXT NOT NULL, + team TEXT, + kills INTEGER NOT NULL DEFAULT 0, + deaths INTEGER NOT NULL DEFAULT 0, + teamkills INTEGER NOT NULL DEFAULT 0, + deaths_by_teamkill INTEGER NOT NULL DEFAULT 0, + weapons_json TEXT NOT NULL DEFAULT '{}', + death_by_weapons_json TEXT NOT NULL DEFAULT '{}', + most_killed_json TEXT NOT NULL DEFAULT '{}', + death_by_json TEXT NOT NULL DEFAULT '{}', + first_seen_server_time BIGINT, + last_seen_server_time BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(target_key, match_key, player_id) +); + +CREATE TABLE IF NOT EXISTS rcon_scoreboard_match_candidates ( + id BIGSERIAL PRIMARY KEY, + server_slug TEXT NOT NULL, + external_match_id TEXT NOT NULL, + started_at TEXT, + ended_at TEXT, + map_name TEXT, + map_pretty_name TEXT, + allied_score INTEGER, + axis_score INTEGER, + player_count INTEGER, + match_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(server_slug, external_match_id) +); + +CREATE INDEX IF NOT EXISTS idx_rcon_historical_samples_target_time +ON rcon_historical_samples(target_id, captured_at DESC); +CREATE INDEX IF NOT EXISTS idx_rcon_historical_windows_target_time +ON rcon_historical_competitive_windows(target_id, last_seen_at DESC); +CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_target_time +ON rcon_admin_log_events(target_key, server_time DESC); +CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_type +ON rcon_admin_log_events(event_type); +CREATE INDEX IF NOT EXISTS idx_rcon_player_profile_snapshots_player +ON rcon_player_profile_snapshots(target_key, player_id, source_server_time DESC); +CREATE INDEX IF NOT EXISTS idx_rcon_materialized_matches_recent +ON rcon_materialized_matches(target_key, ended_at DESC, ended_server_time DESC); +CREATE INDEX IF NOT EXISTS idx_rcon_match_player_stats_match +ON rcon_match_player_stats(target_key, match_key); +CREATE INDEX IF NOT EXISTS idx_rcon_scoreboard_candidates_server_end +ON rcon_scoreboard_match_candidates(server_slug, ended_at DESC, started_at DESC); +""" + + +def initialize_postgres_rcon_storage() -> None: + """Create deterministic PostgreSQL schema for migrated RCON domains.""" + with connect_postgres() as connection: + with connection.cursor() as cursor: + cursor.execute(RCON_SCHEMA_SQL) + + +@contextmanager +def connect_postgres(): + """Yield one PostgreSQL connection with dict-shaped rows.""" + try: + import psycopg + from psycopg.rows import dict_row + except ImportError as error: # pragma: no cover - dependency is environment-specific + raise RuntimeError("psycopg is required when HLL_BACKEND_DATABASE_URL is set.") from error + + database_url = get_database_url() + if not database_url: + raise RuntimeError("HLL_BACKEND_DATABASE_URL is required for PostgreSQL RCON storage.") + with psycopg.connect(database_url, row_factory=dict_row) as connection: + yield connection + + +class PostgresCompatConnection: + """Small DB-API shim for RCON SQL shared with SQLite functions.""" + + def __init__(self, connection: Any): + self.connection = connection + + def execute(self, sql: str, params: Iterable[object] | None = None): + normalized = sql.replace("server_time IS ?", "server_time IS NOT DISTINCT FROM ?") + normalized = normalized.replace("?", "%s") + return self.connection.execute(normalized, tuple(params or ())) + + +@contextmanager +def connect_postgres_compat(): + """Yield a query shim that accepts the phase-1 SQLite-style placeholders.""" + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + yield PostgresCompatConnection(connection) + + +def start_capture_run(*, mode: str, target_scope: str) -> int: + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + row = connection.execute( + """ + INSERT INTO rcon_historical_capture_runs (mode, status, target_scope, started_at) + VALUES (%s, 'running', %s, %s) + RETURNING id + """, + (mode, target_scope, _utc_now_iso()), + ).fetchone() + return int(row["id"]) + + +def finalize_capture_run( + run_id: int, + *, + status: str, + targets_seen: int, + samples_inserted: int, + duplicate_samples: int, + failed_targets: int, + notes: str | None, +) -> None: + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + connection.execute( + """ + UPDATE rcon_historical_capture_runs + SET status = %s, + completed_at = %s, + targets_seen = %s, + samples_inserted = %s, + duplicate_samples = %s, + failed_targets = %s, + notes = %s + WHERE id = %s + """, + ( + status, + _utc_now_iso(), + targets_seen, + samples_inserted, + duplicate_samples, + failed_targets, + notes, + run_id, + ), + ) + + +def persist_sample( + *, + run_id: int, + captured_at: str, + target: Mapping[str, object], + normalized_payload: Mapping[str, object], + raw_payload: Mapping[str, object] | None, +) -> dict[str, int]: + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + target_id = _upsert_target(connection, target=target) + row = connection.execute( + """ + INSERT INTO rcon_historical_samples ( + target_id, capture_run_id, captured_at, source_kind, status, players, + max_players, current_map, normalized_payload_json, raw_payload_json + ) VALUES (%s, %s, %s, 'rcon-live-sample', %s, %s, %s, %s, %s, %s) + ON CONFLICT(target_id, captured_at) DO NOTHING + RETURNING id + """, + ( + target_id, + run_id, + captured_at, + normalized_payload.get("status") or "unknown", + normalized_payload.get("players"), + normalized_payload.get("max_players"), + normalized_payload.get("current_map"), + json.dumps(dict(normalized_payload), separators=(",", ":")), + json.dumps(dict(raw_payload), separators=(",", ":")) if raw_payload else None, + ), + ).fetchone() + inserted = int(row is not None) + _upsert_checkpoint_success( + connection, + target_id=target_id, + run_id=run_id, + captured_at=captured_at, + ) + if inserted: + _upsert_competitive_window( + connection, + target_id=target_id, + captured_at=captured_at, + normalized_payload=normalized_payload, + ) + return {"samples_inserted": inserted, "duplicate_samples": 0 if inserted else 1} + + +def mark_capture_failure( + *, + run_id: int, + target: Mapping[str, object], + error_message: str, +) -> None: + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + target_id = _upsert_target(connection, target=target) + connection.execute( + """ + INSERT INTO rcon_historical_checkpoints ( + target_id, last_run_id, last_run_status, last_error, last_error_at + ) VALUES (%s, %s, 'failed', %s, %s) + ON CONFLICT(target_id) DO UPDATE SET + last_run_id = EXCLUDED.last_run_id, + last_run_status = EXCLUDED.last_run_status, + last_error = EXCLUDED.last_error, + last_error_at = EXCLUDED.last_error_at, + updated_at = CURRENT_TIMESTAMP + """, + (target_id, run_id, error_message, _utc_now_iso()), + ) + + +def list_target_statuses() -> list[dict[str, object]]: + rows = _fetchall( + """ + SELECT + targets.target_key, + targets.external_server_id, + targets.display_name, + targets.host, + targets.port, + targets.region, + targets.source_name, + checkpoints.last_successful_capture_at, + checkpoints.last_sample_at, + checkpoints.last_run_id, + checkpoints.last_run_status, + checkpoints.last_error, + checkpoints.last_error_at, + (SELECT MIN(samples.captured_at) FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id) AS first_sample_at, + (SELECT MAX(samples.captured_at) FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id) AS latest_sample_at, + (SELECT COUNT(*) FROM rcon_historical_samples AS samples + WHERE samples.target_id = targets.id) AS sample_count + FROM rcon_historical_targets AS targets + LEFT JOIN rcon_historical_checkpoints AS checkpoints + ON checkpoints.target_id = targets.id + ORDER BY targets.display_name ASC, targets.target_key ASC + """ + ) + return [ + { + **dict(row), + "sample_count": int(row["sample_count"] or 0), + "last_sample_at": row["latest_sample_at"] or row["last_sample_at"], + } + for row in rows + ] + + +def list_recent_samples(*, target_key: str | None, limit: int) -> list[dict[str, object]]: + where_clause, params = _target_where_clause(target_key) + rows = _fetchall( + f""" + SELECT targets.target_key, targets.external_server_id, targets.display_name, + targets.region, samples.captured_at, samples.status, samples.players, + samples.max_players, samples.current_map + FROM rcon_historical_samples AS samples + INNER JOIN rcon_historical_targets AS targets ON targets.id = samples.target_id + {where_clause} + ORDER BY samples.captured_at DESC, targets.display_name ASC + LIMIT %s + """, + [*params, limit], + ) + return [dict(row) for row in rows] + + +def list_competitive_windows(*, target_key: str | None, limit: int) -> list[dict[str, object]]: + where_clause, params = _target_where_clause(target_key) + rows = _fetchall( + f""" + SELECT targets.target_key, targets.external_server_id, targets.display_name, + targets.region, windows.session_key, windows.map_name, + windows.map_pretty_name, windows.first_seen_at, windows.last_seen_at, + windows.sample_count, windows.total_players, windows.peak_players, + windows.last_players, windows.max_players, windows.status, + windows.confidence_mode, windows.capabilities_json, + windows.latest_payload_json + FROM rcon_historical_competitive_windows AS windows + INNER JOIN rcon_historical_targets AS targets ON targets.id = windows.target_id + {where_clause} + ORDER BY windows.last_seen_at DESC, targets.display_name ASC + LIMIT %s + """, + [*params, limit], + ) + return [_serialize_window(row) for row in rows] + + +def count_samples_since(since: str | None) -> int: + if not since: + return 0 + row = _fetchone( + "SELECT COUNT(*) AS sample_count FROM rcon_historical_samples WHERE captured_at > %s", + (since,), + ) + return int(row["sample_count"] or 0) if row else 0 + + +def list_competitive_summary_rows(*, target_key: str | None) -> list[dict[str, object]]: + where_clause, params = _target_where_clause(target_key) + rows = _fetchall( + f""" + SELECT targets.target_key, targets.external_server_id, targets.display_name, + targets.region, checkpoints.last_successful_capture_at, + checkpoints.last_run_status, checkpoints.last_error, + checkpoints.last_error_at, COUNT(windows.id) AS window_count, + COALESCE(SUM(windows.sample_count), 0) AS sample_count, + MIN(windows.first_seen_at) AS first_seen_at, + MAX(windows.last_seen_at) AS last_seen_at, + COALESCE(MAX(windows.peak_players), 0) AS peak_players + FROM rcon_historical_targets AS targets + LEFT JOIN rcon_historical_checkpoints AS checkpoints ON checkpoints.target_id = targets.id + LEFT JOIN rcon_historical_competitive_windows AS windows ON windows.target_id = targets.id + {where_clause} + GROUP BY targets.id, checkpoints.target_id + ORDER BY targets.display_name ASC, targets.target_key ASC + """, + params, + ) + return [ + { + **dict(row), + "window_count": int(row["window_count"] or 0), + "sample_count": int(row["sample_count"] or 0), + "peak_players": int(row["peak_players"] or 0), + } + for row in rows + ] + + +def find_competitive_window( + *, + server_key: str, + ended_at: str | None, + map_name: str | None, +) -> dict[str, object] | None: + if not ended_at: + return None + aliases = _expand_target_key_aliases(server_key) + candidates = _fetchall( + """ + SELECT windows.session_key, windows.first_seen_at, windows.last_seen_at, + windows.map_name, windows.map_pretty_name, windows.sample_count, + windows.total_players, windows.peak_players, windows.confidence_mode, + windows.capabilities_json, windows.latest_payload_json + FROM rcon_historical_competitive_windows AS windows + INNER JOIN rcon_historical_targets AS targets ON targets.id = windows.target_id + WHERE targets.target_key = ANY(%s) OR targets.external_server_id = ANY(%s) + ORDER BY windows.last_seen_at DESC + LIMIT 12 + """, + (aliases, aliases), + ) + ended_point = _parse_timestamp(ended_at) + if ended_point is None: + return None + normalized_map_name = normalize_map_name(map_name) + best_row: dict[str, object] | None = None + best_distance: float | None = None + for row in candidates: + row_map = normalize_map_name(row["map_pretty_name"] or row["map_name"]) + if normalized_map_name and row_map and normalized_map_name != row_map: + continue + row_last = _parse_timestamp(row["last_seen_at"]) + if row_last is None: + continue + distance = abs((row_last - ended_point).total_seconds()) + if best_distance is None or distance < best_distance: + best_row = dict(row) + best_distance = distance + if best_row is None or best_distance is None or best_distance > 21600: + return None + sample_count = int(best_row["sample_count"] or 0) + return { + "session_key": best_row["session_key"], + "first_seen_at": best_row["first_seen_at"], + "last_seen_at": best_row["last_seen_at"], + "duration_seconds": _calculate_duration_seconds( + best_row["first_seen_at"], + best_row["last_seen_at"], + ), + "map_name": best_row["map_name"], + "map_pretty_name": best_row["map_pretty_name"] or best_row["map_name"], + "sample_count": sample_count, + "average_players": ( + round((int(best_row["total_players"] or 0) / sample_count), 2) + if sample_count > 0 + else 0.0 + ), + "peak_players": int(best_row["peak_players"] or 0), + "confidence_mode": best_row["confidence_mode"], + "capabilities": _deserialize_json_object(best_row["capabilities_json"]), + } + + +def get_competitive_window_by_session( + *, + server_key: str, + session_key: str, +) -> dict[str, object] | None: + normalized_session_key = str(session_key or "").strip() + if not normalized_session_key: + return None + aliases = _expand_target_key_aliases(server_key) + row = _fetchone( + """ + SELECT targets.target_key, targets.external_server_id, targets.display_name, + targets.region, windows.session_key, windows.map_name, + windows.map_pretty_name, windows.first_seen_at, windows.last_seen_at, + windows.sample_count, windows.total_players, windows.peak_players, + windows.confidence_mode, windows.capabilities_json, + windows.latest_payload_json + FROM rcon_historical_competitive_windows AS windows + INNER JOIN rcon_historical_targets AS targets ON targets.id = windows.target_id + WHERE windows.session_key = %s + AND (targets.target_key = ANY(%s) OR targets.external_server_id = ANY(%s)) + LIMIT 1 + """, + (normalized_session_key, aliases, aliases), + ) + return _serialize_window(row) if row else None + + +def list_scoreboard_candidates(*, server_slug: str, limit: int) -> list[dict[str, object]]: + rows = _fetchall( + """ + SELECT external_match_id, started_at, ended_at, map_name, map_pretty_name, + allied_score, axis_score, player_count, match_url + FROM rcon_scoreboard_match_candidates + WHERE server_slug = %s + ORDER BY COALESCE(ended_at, started_at) DESC + LIMIT %s + """, + (server_slug, limit), + ) + return [dict(row) for row in rows] + + +def upsert_scoreboard_candidates( + *, + server_slug: str, + candidates: Iterable[Mapping[str, object]], +) -> int: + """Cache trusted scoreboard correlation candidates in PostgreSQL.""" + rows = [candidate for candidate in candidates if candidate.get("match_url")] + if not rows: + return 0 + initialize_postgres_rcon_storage() + inserted_or_updated = 0 + with connect_postgres() as connection: + for candidate in rows: + connection.execute( + """ + INSERT INTO rcon_scoreboard_match_candidates ( + server_slug, external_match_id, started_at, ended_at, map_name, + map_pretty_name, allied_score, axis_score, player_count, match_url + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT(server_slug, external_match_id) DO UPDATE SET + started_at = EXCLUDED.started_at, + ended_at = EXCLUDED.ended_at, + map_name = EXCLUDED.map_name, + map_pretty_name = EXCLUDED.map_pretty_name, + allied_score = EXCLUDED.allied_score, + axis_score = EXCLUDED.axis_score, + player_count = EXCLUDED.player_count, + match_url = EXCLUDED.match_url, + updated_at = CURRENT_TIMESTAMP + """, + ( + server_slug, + str(candidate.get("external_match_id") or ""), + candidate.get("started_at"), + candidate.get("ended_at"), + candidate.get("map_name"), + candidate.get("map_pretty_name"), + candidate.get("allied_score"), + candidate.get("axis_score"), + candidate.get("player_count"), + candidate["match_url"], + ), + ) + inserted_or_updated += 1 + return inserted_or_updated + + +def count_migrated_tables() -> dict[str, int]: + initialize_postgres_rcon_storage() + table_names = ( + "rcon_admin_log_events", + "rcon_player_profile_snapshots", + "rcon_materialized_matches", + "rcon_match_player_stats", + "rcon_historical_targets", + "rcon_historical_samples", + "rcon_historical_competitive_windows", + "rcon_scoreboard_match_candidates", + ) + with connect_postgres() as connection: + return { + table_name: int( + connection.execute(f"SELECT COUNT(*) AS count FROM {table_name}").fetchone()[ + "count" + ] + or 0 + ) + for table_name in table_names + } + + +def _fetchall(sql: str, params: Iterable[object] = ()) -> list[dict[str, object]]: + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + return [dict(row) for row in connection.execute(sql, tuple(params)).fetchall()] + + +def _fetchone(sql: str, params: Iterable[object] = ()) -> dict[str, object] | None: + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + row = connection.execute(sql, tuple(params)).fetchone() + return dict(row) if row else None + + +def _upsert_target(connection: Any, *, target: Mapping[str, object]) -> int: + target_key = str(target.get("target_key") or "").strip() + display_name = str(target.get("name") or target.get("display_name") or target_key).strip() + host = str(target.get("host") or "").strip() + port = int(target.get("port") or 0) + if not target_key or not host or port <= 0: + raise ValueError("Prospective RCON targets require target_key, host and port.") + row = connection.execute( + """ + INSERT INTO rcon_historical_targets ( + target_key, external_server_id, display_name, host, port, region, + game_port, query_port, source_name, last_configured_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT(target_key) DO UPDATE SET + external_server_id = EXCLUDED.external_server_id, + display_name = EXCLUDED.display_name, + host = EXCLUDED.host, + port = EXCLUDED.port, + region = EXCLUDED.region, + game_port = EXCLUDED.game_port, + query_port = EXCLUDED.query_port, + source_name = EXCLUDED.source_name, + last_configured_at = EXCLUDED.last_configured_at, + updated_at = CURRENT_TIMESTAMP + RETURNING id + """, + ( + target_key, + target.get("external_server_id"), + display_name, + host, + port, + target.get("region"), + target.get("game_port"), + target.get("query_port"), + str(target.get("source_name") or "community-hispana-rcon"), + _utc_now_iso(), + ), + ).fetchone() + return int(row["id"]) + + +def _upsert_checkpoint_success( + connection: Any, + *, + target_id: int, + run_id: int, + captured_at: str, +) -> None: + connection.execute( + """ + INSERT INTO rcon_historical_checkpoints ( + target_id, last_successful_capture_at, last_sample_at, last_run_id, + last_run_status, last_error, last_error_at + ) VALUES (%s, %s, %s, %s, 'success', NULL, NULL) + ON CONFLICT(target_id) DO UPDATE SET + last_successful_capture_at = EXCLUDED.last_successful_capture_at, + last_sample_at = EXCLUDED.last_sample_at, + last_run_id = EXCLUDED.last_run_id, + last_run_status = EXCLUDED.last_run_status, + last_error = NULL, + last_error_at = NULL, + updated_at = CURRENT_TIMESTAMP + """, + (target_id, captured_at, captured_at, run_id), + ) + + +def _upsert_competitive_window( + connection: Any, + *, + target_id: int, + captured_at: str, + normalized_payload: Mapping[str, object], +) -> None: + current_map_raw = str(normalized_payload.get("current_map") or "").strip() + if not current_map_raw: + return + map_pretty_name = normalize_map_name(current_map_raw) or current_map_raw + players = int(normalized_payload.get("players") or 0) + max_players = normalized_payload.get("max_players") + status = str(normalized_payload.get("status") or "unknown") + latest_window = connection.execute( + """ + SELECT * + FROM rcon_historical_competitive_windows + WHERE target_id = %s + ORDER BY last_seen_at DESC, id DESC + LIMIT 1 + """, + (target_id,), + ).fetchone() + if latest_window and _should_extend_competitive_window( + latest_window=dict(latest_window), + captured_at=captured_at, + current_map=current_map_raw, + ): + connection.execute( + """ + UPDATE rcon_historical_competitive_windows + SET map_name = %s, + map_pretty_name = %s, + last_seen_at = %s, + sample_count = sample_count + 1, + total_players = total_players + %s, + peak_players = GREATEST(peak_players, %s), + last_players = %s, + max_players = %s, + status = %s, + confidence_mode = %s, + capabilities_json = %s, + latest_payload_json = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + ( + current_map_raw, + map_pretty_name, + captured_at, + players, + players, + players, + max_players, + status, + COMPETITIVE_MODE_APPROXIMATE, + json.dumps(_build_competitive_capabilities(), separators=(",", ":")), + json.dumps(dict(normalized_payload), separators=(",", ":")), + latest_window["id"], + ), + ) + return + connection.execute( + """ + INSERT INTO rcon_historical_competitive_windows ( + target_id, session_key, source_kind, map_name, map_pretty_name, + first_seen_at, last_seen_at, sample_count, total_players, + peak_players, last_players, max_players, status, confidence_mode, + capabilities_json, latest_payload_json + ) VALUES (%s, %s, 'rcon-historical-samples', %s, %s, %s, %s, 1, + %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + target_id, + f"{target_id}:{captured_at}", + current_map_raw, + map_pretty_name, + captured_at, + captured_at, + players, + players, + players, + max_players, + status, + COMPETITIVE_MODE_APPROXIMATE, + json.dumps(_build_competitive_capabilities(), separators=(",", ":")), + json.dumps(dict(normalized_payload), separators=(",", ":")), + ), + ) + + +def _target_where_clause(target_key: str | None) -> tuple[str, list[object]]: + if not target_key: + return "", [] + aliases = _expand_target_key_aliases(target_key) + return "WHERE targets.target_key = ANY(%s) OR targets.external_server_id = ANY(%s)", [ + aliases, + aliases, + ] + + +def _expand_target_key_aliases(target_key: str) -> list[str]: + normalized_target_key = str(target_key or "").strip() + aliases = {normalized_target_key} + try: + configured_targets = load_rcon_targets() + except Exception: + configured_targets = () + for target in configured_targets: + external_server_id = str(target.external_server_id or "").strip() + legacy_target_key = f"rcon:{target.host}:{target.port}" + if external_server_id and external_server_id == normalized_target_key: + aliases.update((legacy_target_key, external_server_id)) + elif legacy_target_key == normalized_target_key: + aliases.add(legacy_target_key) + if external_server_id: + aliases.add(external_server_id) + return sorted(alias for alias in aliases if alias) + + +def _serialize_window(row: Mapping[str, object]) -> dict[str, object]: + sample_count = int(row["sample_count"] or 0) + return { + "target_key": row["target_key"], + "external_server_id": row["external_server_id"], + "display_name": row["display_name"], + "region": row["region"], + "session_key": row["session_key"], + "map_name": row["map_name"], + "map_pretty_name": row["map_pretty_name"] or row["map_name"], + "first_seen_at": row["first_seen_at"], + "last_seen_at": row["last_seen_at"], + "duration_seconds": _calculate_duration_seconds( + row["first_seen_at"], + row["last_seen_at"], + ), + "sample_count": sample_count, + "average_players": ( + round((int(row["total_players"] or 0) / sample_count), 2) + if sample_count > 0 + else 0.0 + ), + "peak_players": int(row["peak_players"] or 0), + "last_players": row.get("last_players"), + "max_players": row.get("max_players"), + "status": row.get("status"), + "confidence_mode": row["confidence_mode"], + "capabilities": _deserialize_json_object(row["capabilities_json"]), + "latest_payload": _deserialize_json_object(row["latest_payload_json"]), + } + + +def _should_extend_competitive_window( + *, + latest_window: Mapping[str, object], + captured_at: str, + current_map: str, +) -> bool: + if normalize_map_name(latest_window.get("map_name")) != normalize_map_name(current_map): + return False + latest_seen = _parse_timestamp(latest_window.get("last_seen_at")) + captured_point = _parse_timestamp(captured_at) + if latest_seen is None or captured_point is None: + return False + return (captured_point - latest_seen).total_seconds() <= COMPETITIVE_WINDOW_GAP_SECONDS + + +def _build_competitive_capabilities() -> dict[str, object]: + return { + "recent_matches": COMPETITIVE_MODE_APPROXIMATE, + "server_summary": COMPETITIVE_MODE_EXACT, + "competitive_quality": COMPETITIVE_MODE_PARTIAL, + "result": "session-score", + "gamestate": "session", + "player_stats": "unavailable", + } + + +def _deserialize_json_object(raw_value: object) -> dict[str, object]: + if isinstance(raw_value, str) and raw_value.strip(): + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _calculate_duration_seconds(first_seen_at: object, last_seen_at: object) -> int | None: + first_point = _parse_timestamp(first_seen_at) + last_point = _parse_timestamp(last_seen_at) + if first_point is None or last_point is None: + return None + return max(0, int((last_point - first_point).total_seconds())) + + +def _parse_timestamp(raw_value: object) -> datetime | None: + if not isinstance(raw_value, str) or not raw_value.strip(): + return None + try: + timestamp = datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + except ValueError: + return None + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + return timestamp.astimezone(timezone.utc) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/backend/app/rcon_admin_log_materialization.py b/backend/app/rcon_admin_log_materialization.py index a720b86..92a76b4 100644 --- a/backend/app/rcon_admin_log_materialization.py +++ b/backend/app/rcon_admin_log_materialization.py @@ -10,7 +10,7 @@ from collections.abc import Iterable from contextlib import closing from pathlib import Path -from .config import get_storage_path +from .config import get_storage_path, use_postgres_rcon_storage from .normalizers import normalize_map_name from .rcon_admin_log_storage import initialize_rcon_admin_log_storage from .rcon_historical_storage import list_rcon_historical_competitive_windows @@ -23,6 +23,12 @@ SESSION_RESULT_SOURCE = "rcon-session" def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path: """Create SQLite structures used by the materialized RCON match pipeline.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import initialize_postgres_rcon_storage + + initialize_postgres_rcon_storage() + return get_storage_path() + resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) with closing(connect_sqlite_writer(resolved_path)) as connection: with connection: @@ -85,6 +91,46 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, object]: """Materialize matches and player stats from stored AdminLog events.""" resolved_path = initialize_rcon_materialized_storage(db_path=db_path) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + with connect_postgres_compat() as connection: + payload = _materialize_rcon_admin_log_with_connection( + connection, + session_window_db_path=None, + caught_errors=(Exception,), + ) + freshness = summarize_rcon_materialization_status() + return { + **payload, + "latest_materialized_matches": freshness["latest_materialized_matches"], + "latest_admin_log_match_end_events": freshness["latest_admin_log_match_end_events"], + "match_end_status": freshness["match_end_status"], + } + + with closing(connect_sqlite_writer(resolved_path)) as connection: + with connection: + payload = _materialize_rcon_admin_log_with_connection( + connection, + session_window_db_path=resolved_path, + caught_errors=(sqlite3.Error,), + ) + + freshness = summarize_rcon_materialization_status(db_path=resolved_path) + return { + **payload, + "latest_materialized_matches": freshness["latest_materialized_matches"], + "latest_admin_log_match_end_events": freshness["latest_admin_log_match_end_events"], + "match_end_status": freshness["match_end_status"], + } + + +def _materialize_rcon_admin_log_with_connection( + connection: object, + *, + session_window_db_path: Path | None, + caught_errors: tuple[type[BaseException], ...], +) -> dict[str, object]: errors: list[str] = [] matches_seen = 0 matches_materialized = 0 @@ -93,40 +139,39 @@ def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, obje player_stats_materialized = 0 player_stats_updated = 0 - with closing(connect_sqlite_writer(resolved_path)) as connection: - with connection: - try: - match_rows = _derive_admin_log_matches(connection) - matches_seen = len(match_rows) - for row in match_rows: - outcome = _upsert_match(connection, row) - matches_materialized += int(outcome == "inserted") - matches_updated += int(outcome == "updated") - session_rows = _derive_session_fallback_matches(connection, db_path=resolved_path) - matches_seen += len(session_rows) - for row in session_rows: - outcome = _upsert_match(connection, row) - matches_materialized += int(outcome == "inserted") - matches_updated += int(outcome == "updated") + try: + match_rows = _derive_admin_log_matches(connection) + matches_seen = len(match_rows) + for row in match_rows: + outcome = _upsert_match(connection, row) + matches_materialized += int(outcome == "inserted") + matches_updated += int(outcome == "updated") + session_rows = _derive_session_fallback_matches( + connection, + db_path=session_window_db_path, + ) + matches_seen += len(session_rows) + for row in session_rows: + outcome = _upsert_match(connection, row) + matches_materialized += int(outcome == "inserted") + matches_updated += int(outcome == "updated") - persisted_matches = _list_materialized_matches(connection) - for match in persisted_matches: - stats = _derive_player_stats_for_match(connection, match) - player_stats_seen += len(stats) - connection.execute( - """ - DELETE FROM rcon_match_player_stats - WHERE target_key = ? AND match_key = ? - """, - (match["target_key"], match["match_key"]), - ) - for stat in stats: - _insert_player_stat(connection, stat) - player_stats_materialized += 1 - except sqlite3.Error as error: - errors.append(str(error)) - - freshness = summarize_rcon_materialization_status(db_path=resolved_path) + persisted_matches = _list_materialized_matches(connection) + for match in persisted_matches: + stats = _derive_player_stats_for_match(connection, match) + player_stats_seen += len(stats) + connection.execute( + """ + DELETE FROM rcon_match_player_stats + WHERE target_key = ? AND match_key = ? + """, + (match["target_key"], match["match_key"]), + ) + for stat in stats: + _insert_player_stat(connection, stat) + player_stats_materialized += 1 + except caught_errors as error: + errors.append(str(error)) return { "matches_seen": matches_seen, "matches_materialized": matches_materialized, @@ -135,8 +180,6 @@ def materialize_rcon_admin_log(*, db_path: Path | None = None) -> dict[str, obje "player_stats_materialized": player_stats_materialized, "player_stats_updated": player_stats_updated, "errors": errors, - "latest_materialized_matches": freshness["latest_materialized_matches"], - "latest_admin_log_match_end_events": freshness["latest_admin_log_match_end_events"], } @@ -159,7 +202,13 @@ def list_materialized_rcon_matches( params.append(MATCH_RESULT_SOURCE) where = "WHERE " + " AND ".join(clauses) if clauses else "" params.append(limit) - with closing(connect_sqlite_readonly(resolved_path)) as connection: + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + connection_scope = connect_postgres_compat() + else: + connection_scope = closing(connect_sqlite_readonly(resolved_path)) + with connection_scope as connection: rows = connection.execute( f""" SELECT @@ -196,7 +245,13 @@ def get_materialized_rcon_match_detail( ) -> dict[str, object] | None: """Return one materialized match with player stats.""" resolved_path = initialize_rcon_materialized_storage(db_path=db_path) - with closing(connect_sqlite_readonly(resolved_path)) as connection: + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + connection_scope = connect_postgres_compat() + else: + connection_scope = closing(connect_sqlite_readonly(resolved_path)) + with connection_scope as connection: match = connection.execute( """ SELECT * @@ -248,7 +303,13 @@ def get_materialized_rcon_match_detail( def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dict[str, object]: """Return a small diagnostic summary for stored RCON materialization state.""" resolved_path = initialize_rcon_materialized_storage(db_path=db_path) - with closing(connect_sqlite_readonly(resolved_path)) as connection: + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + connection_scope = connect_postgres_compat() + else: + connection_scope = closing(connect_sqlite_readonly(resolved_path)) + with connection_scope as connection: match_count = connection.execute( "SELECT COUNT(*) AS count FROM rcon_materialized_matches" ).fetchone()["count"] @@ -259,7 +320,7 @@ def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dic SELECT 1 FROM rcon_match_player_stats GROUP BY target_key, match_key - ) + ) AS stats_matches """ ).fetchone()["count"] ranges = connection.execute( @@ -301,7 +362,7 @@ def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dic ) AS row_number FROM rcon_materialized_matches WHERE source_basis = ? - ) + ) AS ranked_matches WHERE row_number = 1 ORDER BY target_key ASC """, @@ -328,6 +389,11 @@ def summarize_rcon_materialization_status(*, db_path: Path | None = None) -> dic "event_counts": [dict(row) for row in event_counts], "latest_materialized_matches": [dict(row) for row in latest_matches], "latest_admin_log_match_end_events": [dict(row) for row in latest_match_end_events], + "match_end_status": ( + "admin-log-match-end-events-available" + if latest_match_end_events + else "no-admin-log-match-end-events-stored" + ), } @@ -360,7 +426,7 @@ def _derive_admin_log_matches(connection: sqlite3.Connection) -> list[dict[str, def _derive_session_fallback_matches( connection: sqlite3.Connection, *, - db_path: Path, + db_path: Path | None, ) -> list[dict[str, object]]: rows: list[dict[str, object]] = [] existing = { diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py index da57346..8b00ea6 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -8,7 +8,7 @@ import sqlite3 from collections.abc import Mapping from pathlib import Path -from .config import get_storage_path +from .config import get_storage_path, use_postgres_rcon_storage from .rcon_admin_log_parser import parse_rcon_admin_log_entry from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot from .rcon_historical_storage import initialize_rcon_historical_storage @@ -17,6 +17,12 @@ from .sqlite_utils import connect_sqlite_writer def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path: """Create SQLite structures for parsed RCON AdminLog events.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import initialize_postgres_rcon_storage + + initialize_postgres_rcon_storage() + return get_storage_path() + resolved_path = initialize_rcon_historical_storage(db_path=db_path) with connect_sqlite_writer(resolved_path) as connection: @@ -90,6 +96,9 @@ def persist_rcon_admin_log_entries( db_path: Path | None = None, ) -> dict[str, int]: """Persist raw and parsed AdminLog entries idempotently.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + return _persist_rcon_admin_log_entries_postgres(target=target, entries=entries) + resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) target_key = str(target.get("target_key") or target.get("external_server_id") or "") if not target_key: @@ -278,6 +287,25 @@ def _ensure_canonical_message_column(connection: sqlite3.Connection) -> None: def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dict[str, object]]: """Return event counts grouped by target and event type.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + with connect_postgres_compat() as connection: + rows = connection.execute( + """ + SELECT + target_key, + event_type, + COUNT(*) AS event_count, + MIN(server_time) AS first_server_time, + MAX(server_time) AS last_server_time + FROM rcon_admin_log_events + GROUP BY target_key, event_type + ORDER BY target_key ASC, event_count DESC + """ + ).fetchall() + return [dict(row) for row in rows] + resolved_path = db_path or get_storage_path() initialize_rcon_admin_log_storage(db_path=resolved_path) @@ -310,6 +338,29 @@ def get_latest_rcon_player_profile_summaries( requested_ids = [str(player_id).strip() for player_id in player_ids if str(player_id).strip()] if not target_key or not requested_ids: return {} + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + placeholders = ",".join("?" for _ in requested_ids) + with connect_postgres_compat() as connection: + rows = connection.execute( + f""" + SELECT snapshots.* + FROM rcon_player_profile_snapshots AS snapshots + INNER JOIN ( + SELECT player_id, MAX(source_server_time) AS latest_source_server_time + FROM rcon_player_profile_snapshots + WHERE target_key = ? + AND player_id IN ({placeholders}) + GROUP BY player_id + ) AS latest + ON latest.player_id = snapshots.player_id + AND latest.latest_source_server_time = snapshots.source_server_time + WHERE snapshots.target_key = ? + """, + [target_key, *requested_ids, target_key], + ).fetchall() + return {str(row["player_id"]): _build_safe_profile_summary(row) for row in rows} resolved_path = db_path or get_storage_path() initialize_rcon_admin_log_storage(db_path=resolved_path) @@ -369,3 +420,68 @@ def _json_mapping(raw_value: object) -> dict[str, object]: except json.JSONDecodeError: return {} return parsed if isinstance(parsed, dict) else {} + + +def _persist_rcon_admin_log_entries_postgres( + *, + target: Mapping[str, object], + entries: list[dict[str, object]], +) -> dict[str, int]: + from .postgres_rcon_storage import connect_postgres_compat + + target_key = str(target.get("target_key") or target.get("external_server_id") or "") + if not target_key: + raise ValueError("target must include target_key or external_server_id") + + external_server_id = target.get("external_server_id") + inserted = 0 + duplicates = 0 + with connect_postgres_compat() as connection: + for entry in entries: + parsed = parse_rcon_admin_log_entry(entry) + raw_message = str(parsed.get("raw_message") or "") + canonical_message = _canonicalize_admin_log_message(raw_message) + cursor = connection.execute( + """ + INSERT INTO rcon_admin_log_events ( + target_key, + external_server_id, + event_timestamp, + server_time, + relative_time, + event_type, + raw_message, + canonical_message, + parsed_payload_json, + raw_entry_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + """, + ( + target_key, + external_server_id, + parsed.get("timestamp"), + parsed.get("server_time"), + parsed.get("relative_time"), + parsed.get("event_type") or "unknown", + raw_message, + canonical_message, + json.dumps(parsed, ensure_ascii=False, separators=(",", ":")), + json.dumps(entry, ensure_ascii=False, separators=(",", ":")), + ), + ) + if int(cursor.rowcount or 0): + inserted += 1 + else: + duplicates += 1 + _persist_profile_snapshot_if_present( + connection, + target_key=target_key, + external_server_id=external_server_id, + parsed=parsed, + ) + return { + "events_seen": len(entries), + "events_inserted": inserted, + "duplicate_events": duplicates, + } diff --git a/backend/app/rcon_historical_storage.py b/backend/app/rcon_historical_storage.py index 8369da3..30d75b4 100644 --- a/backend/app/rcon_historical_storage.py +++ b/backend/app/rcon_historical_storage.py @@ -8,7 +8,7 @@ from collections.abc import Mapping from datetime import datetime, timezone from pathlib import Path -from .config import get_storage_path +from .config import get_storage_path, use_postgres_rcon_storage from .normalizers import normalize_map_name from .rcon_client import load_rcon_targets from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer @@ -22,6 +22,12 @@ COMPETITIVE_MODE_EXACT = "exact" def initialize_rcon_historical_storage(*, db_path: Path | None = None) -> Path: """Create the SQLite structures used by prospective RCON capture.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import initialize_postgres_rcon_storage + + initialize_postgres_rcon_storage() + return get_storage_path() + resolved_path = db_path or get_storage_path() resolved_path.parent.mkdir(parents=True, exist_ok=True) @@ -131,6 +137,11 @@ def start_rcon_historical_capture_run( db_path: Path | None = None, ) -> int: """Create one run row for prospective RCON capture.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import start_capture_run + + return start_capture_run(mode=mode, target_scope=target_scope) + resolved_path = initialize_rcon_historical_storage(db_path=db_path) with _connect(resolved_path) as connection: cursor = connection.execute( @@ -159,6 +170,20 @@ def finalize_rcon_historical_capture_run( db_path: Path | None = None, ) -> None: """Finalize one prospective RCON capture run.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import finalize_capture_run + + finalize_capture_run( + run_id, + status=status, + targets_seen=targets_seen, + samples_inserted=samples_inserted, + duplicate_samples=duplicate_samples, + failed_targets=failed_targets, + notes=notes, + ) + return + resolved_path = initialize_rcon_historical_storage(db_path=db_path) with _connect(resolved_path) as connection: connection.execute( @@ -196,6 +221,17 @@ def persist_rcon_historical_sample( db_path: Path | None = None, ) -> dict[str, int]: """Persist one prospective RCON sample and refresh its checkpoint.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import persist_sample + + return persist_sample( + run_id=run_id, + captured_at=captured_at, + target=target, + normalized_payload=normalized_payload, + raw_payload=raw_payload, + ) + resolved_path = initialize_rcon_historical_storage(db_path=db_path) with _connect(resolved_path) as connection: target_id = _upsert_target(connection, target=target) @@ -254,6 +290,12 @@ def mark_rcon_historical_capture_failure( db_path: Path | None = None, ) -> None: """Persist failure metadata for one target inside a capture run.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import mark_capture_failure + + mark_capture_failure(run_id=run_id, target=target, error_message=error_message) + return + resolved_path = initialize_rcon_historical_storage(db_path=db_path) with _connect(resolved_path) as connection: target_id = _upsert_target(connection, target=target) @@ -282,6 +324,11 @@ def list_rcon_historical_target_statuses( db_path: Path | None = None, ) -> list[dict[str, object]]: """Return per-target coverage and freshness for prospective RCON capture.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import list_target_statuses + + return list_target_statuses() + resolved_path = _resolve_db_path(db_path) try: with _connect_readonly(resolved_path) as connection: @@ -353,6 +400,11 @@ def list_recent_rcon_historical_samples( db_path: Path | None = None, ) -> list[dict[str, object]]: """Return recent prospective RCON samples for one or all configured targets.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import list_recent_samples + + return list_recent_samples(target_key=target_key, limit=limit) + resolved_path = _resolve_db_path(db_path) where_clause = "" params: list[object] = [limit] @@ -413,6 +465,11 @@ def list_rcon_historical_competitive_windows( db_path: Path | None = None, ) -> list[dict[str, object]]: """Return recent RCON-backed competitive windows derived from persisted samples.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import list_competitive_windows + + return list_competitive_windows(target_key=target_key, limit=limit) + resolved_path = _resolve_db_path(db_path) where_clause = "" params: list[object] = [limit] @@ -498,6 +555,11 @@ def count_rcon_historical_samples_since( db_path: Path | None = None, ) -> int: """Return how many RCON samples were captured after one timestamp.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import count_samples_since + + return count_samples_since(since) + if not since: return 0 resolved_path = _resolve_db_path(db_path) @@ -522,6 +584,11 @@ def list_rcon_historical_competitive_summary_rows( db_path: Path | None = None, ) -> list[dict[str, object]]: """Return RCON-backed per-target summary rows over competitive windows.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import list_competitive_summary_rows + + return list_competitive_summary_rows(target_key=target_key) + resolved_path = _resolve_db_path(db_path) where_clause = "" params: list[object] = [] @@ -593,6 +660,15 @@ def find_rcon_historical_competitive_window( db_path: Path | None = None, ) -> dict[str, object] | None: """Return the closest competitive window for one server/match if coverage exists.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import find_competitive_window + + return find_competitive_window( + server_key=server_key, + ended_at=ended_at, + map_name=map_name, + ) + if not ended_at: return None resolved_path = _resolve_db_path(db_path) @@ -672,6 +748,14 @@ def get_rcon_historical_competitive_window_by_session( db_path: Path | None = None, ) -> dict[str, object] | None: """Return one persisted competitive RCON window by its synthetic session key.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import get_competitive_window_by_session + + return get_competitive_window_by_session( + server_key=server_key, + session_key=session_key, + ) + normalized_session_key = str(session_key or "").strip() if not normalized_session_key: return None diff --git a/backend/app/rcon_scoreboard_correlation.py b/backend/app/rcon_scoreboard_correlation.py index fd170eb..2f9a5f2 100644 --- a/backend/app/rcon_scoreboard_correlation.py +++ b/backend/app/rcon_scoreboard_correlation.py @@ -6,7 +6,7 @@ import sqlite3 from datetime import datetime, timezone from pathlib import Path -from .config import get_storage_path +from .config import get_storage_path, use_postgres_rcon_storage from .normalizers import normalize_map_name from .scoreboard_origins import resolve_trusted_scoreboard_match_url from .sqlite_utils import connect_sqlite_readonly @@ -76,6 +76,16 @@ def _list_persisted_scoreboard_candidates( server_slug: str, db_path: Path, ) -> list[dict[str, object]]: + if use_postgres_rcon_storage(): + from .postgres_rcon_storage import list_scoreboard_candidates + + postgres_candidates = list_scoreboard_candidates( + server_slug=server_slug, + limit=MAX_CANDIDATES, + ) + if postgres_candidates: + return postgres_candidates + try: with connect_sqlite_readonly(db_path) as connection: rows = connection.execute( @@ -128,6 +138,10 @@ def _list_persisted_scoreboard_candidates( "match_url": match_url, } ) + if items and use_postgres_rcon_storage(): + from .postgres_rcon_storage import upsert_scoreboard_candidates + + upsert_scoreboard_candidates(server_slug=server_slug, candidates=items) return items diff --git a/backend/app/storage_diagnostics.py b/backend/app/storage_diagnostics.py new file mode 100644 index 0000000..fc001ab --- /dev/null +++ b/backend/app/storage_diagnostics.py @@ -0,0 +1,89 @@ +"""Report active phase-1 RCON storage backend and migrated table counts.""" + +from __future__ import annotations + +import json +import sqlite3 +from contextlib import closing + +from .config import get_database_url, get_storage_path, use_postgres_rcon_storage +from .rcon_admin_log_materialization import summarize_rcon_materialization_status +from .rcon_admin_log_storage import initialize_rcon_admin_log_storage +from .sqlite_utils import connect_sqlite_readonly + + +MIGRATED_TABLES = ( + "rcon_admin_log_events", + "rcon_player_profile_snapshots", + "rcon_materialized_matches", + "rcon_match_player_stats", + "rcon_historical_targets", + "rcon_historical_samples", + "rcon_historical_competitive_windows", + "rcon_scoreboard_match_candidates", +) + + +def build_storage_diagnostics() -> dict[str, object]: + """Return one JSON-safe diagnostic payload for the migrated domains.""" + if use_postgres_rcon_storage(): + from .postgres_rcon_storage import count_migrated_tables + + counts = count_migrated_tables() + backend = "postgresql" + else: + counts = _count_sqlite_tables() + backend = "sqlite-fallback" + materialization = summarize_rcon_materialization_status() + return { + "active_storage_backend": backend, + "database_url_configured": bool(get_database_url()), + "sqlite_fallback_path": str(get_storage_path()), + "migrated_domains": [ + "rcon-admin-log-events", + "rcon-player-profile-snapshots", + "rcon-historical-capture-samples-and-windows", + "rcon-materialized-matches", + "rcon-materialized-player-stats", + "rcon-safe-scoreboard-candidates", + ], + "table_counts": counts, + "latest_materialized_matches": materialization["latest_materialized_matches"], + "latest_admin_log_match_end_events": materialization[ + "latest_admin_log_match_end_events" + ], + "match_end_status": materialization["match_end_status"], + "sqlite_remaining": [ + "live server snapshots and /api/servers cache", + "public-scoreboard historical_* tables and scoreboard fallback data", + "historical snapshots, player-event ledger and Elo/MMR tables", + ], + "scoreboard_correlation": ( + "PostgreSQL safe candidates are preferred when present; phase-1 still falls " + "back to trusted persisted historical_* scoreboard rows on SQLite." + ), + } + + +def _count_sqlite_tables() -> dict[str, int]: + resolved_path = initialize_rcon_admin_log_storage() + counts: dict[str, int] = {} + with closing(connect_sqlite_readonly(resolved_path)) as connection: + for table_name in MIGRATED_TABLES: + try: + row = connection.execute( + f"SELECT COUNT(*) AS count FROM {table_name}" + ).fetchone() + except sqlite3.Error: + counts[table_name] = 0 + else: + counts[table_name] = int(row["count"] or 0) + return counts + + +def main() -> None: + print(json.dumps(build_storage_diagnostics(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt index a975eab..6ab0fc9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1 +1,2 @@ -# El bootstrap actual usa solo la libreria estandar de Python. +# PostgreSQL is used by the phase-1 RCON historical storage migration. +psycopg[binary]>=3.2,<4 diff --git a/docker-compose.yml b/docker-compose.yml index 4ff58cd..19e6c3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,22 @@ services: + postgres: + image: postgres:16-alpine + container_name: hll-vietnam-postgres + environment: + POSTGRES_DB: hll_vietnam + POSTGRES_USER: hll_vietnam + POSTGRES_PASSWORD: hll_vietnam_dev + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U hll_vietnam -d hll_vietnam"] + interval: 5s + timeout: 5s + retries: 12 + restart: unless-stopped + backend: build: context: ./backend @@ -6,6 +24,7 @@ services: env_file: - ./backend/.env.example environment: + HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:-postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam} HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} @@ -13,6 +32,9 @@ services: ${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]} ports: - "8000:8000" + depends_on: + postgres: + condition: service_healthy volumes: - ./backend/data:/app/data restart: unless-stopped @@ -27,13 +49,17 @@ services: env_file: - ./backend/.env.example environment: + HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:-postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam} HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} HLL_BACKEND_RCON_TARGETS: >- ${HLL_BACKEND_RCON_TARGETS:-[{"name":"Comunidad Hispana #01","slug":"comunidad-hispana-01","external_server_id":"comunidad-hispana-01","host":"152.114.195.174","port":7779,"password":"replace-me-01","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null},{"name":"Comunidad Hispana #02","slug":"comunidad-hispana-02","external_server_id":"comunidad-hispana-02","host":"152.114.195.150","port":7879,"password":"replace-me-02","source_name":"community-hispana-rcon","region":"ES","game_port":null,"query_port":null}]} depends_on: - - backend + postgres: + condition: service_healthy + backend: + condition: service_started volumes: - ./backend/data:/app/data restart: unless-stopped @@ -48,6 +74,7 @@ services: env_file: - ./backend/.env.example environment: + HLL_BACKEND_DATABASE_URL: ${HLL_BACKEND_DATABASE_URL:-postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam} HLL_BACKEND_LIVE_DATA_SOURCE: ${HLL_BACKEND_LIVE_DATA_SOURCE:-rcon} HLL_BACKEND_HISTORICAL_DATA_SOURCE: ${HLL_BACKEND_HISTORICAL_DATA_SOURCE:-rcon} HLL_BACKEND_RCON_TIMEOUT_SECONDS: ${HLL_BACKEND_RCON_TIMEOUT_SECONDS:-20} @@ -57,7 +84,10 @@ services: HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES: ${HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES:-2} HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS:-15} depends_on: - - backend + postgres: + condition: service_healthy + backend: + condition: service_started volumes: - ./backend/data:/app/data restart: unless-stopped @@ -71,3 +101,6 @@ services: ports: - "8080:8080" restart: unless-stopped + +volumes: + postgres-data: diff --git a/docs/decisions.md b/docs/decisions.md index e93b019..cff44e4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -177,3 +177,31 @@ son confiables `comunidad-hispana-01`, con origen defecto nuevos. Los datos historicos ya persistidos no se eliminan, pero las URLs publicas de partidas solo se aceptan si el `raw_payload_ref` usa HTTP(S), apunta al origen confiable del servidor activo y mantiene una ruta `/games/`. + +## Decision 017: PostgreSQL phase 1 for RCON historical persistence + +La primera migracion de persistencia a PostgreSQL cubre el camino que sufria +contencion SQLite entre `backend`, `historical-runner` y +`rcon-historical-worker`: + +- captura prospectiva RCON, muestras y ventanas competitivas +- eventos AdminLog deduplicados y snapshots de perfil derivados +- partidas RCON materializadas y estadisticas por jugador +- candidatos confiables de URL de scoreboard que puedan poblarse para + correlacion de detalle + +Docker Compose configura `HLL_BACKEND_DATABASE_URL` y usa PostgreSQL como +backend autoritativo para esas tablas. La ejecucion local sin esa variable sigue +usando SQLite como fallback temporal para preservar comandos y tests locales. + +Quedan SQLite-backed en esta fase porque no forman parte del lock-prone writer +path migrado y siguen cubriendo fallback publico o caches locales: + +- snapshots live y cache de `/api/servers` +- tablas `historical_*` de scoreboard publico, rankings y correlacion legacy +- snapshots historicos precalculados, ledger player-event y Elo/MMR pausado + +La correlacion de URL publica en detalle usa primero candidatos PostgreSQL +confiables cuando existan y puede seguir leyendo filas `historical_*` +persistidas en SQLite durante la transicion. El diagnostico operativo se expone +con `python -m app.storage_diagnostics`. diff --git a/frontend/assets/css/historico-scoreboard-detail.css b/frontend/assets/css/historico-scoreboard-detail.css index f1148ce..3b2afbf 100644 --- a/frontend/assets/css/historico-scoreboard-detail.css +++ b/frontend/assets/css/historico-scoreboard-detail.css @@ -39,18 +39,15 @@ } .historical-player-row { - cursor: pointer; outline: 0; } .historical-player-row:hover td, -.historical-player-row:focus td, .historical-player-row:focus-within td, .historical-player-row.is-expanded td { background: rgba(210, 182, 118, 0.06); } -.historical-player-row:focus-visible td, .historical-player-row__details-button:focus-visible { outline: 2px solid rgba(210, 182, 118, 0.78); outline-offset: -2px; @@ -99,11 +96,6 @@ display: none; } -.historical-player-row:hover + .historical-player-detail-row, -.historical-player-row:focus + .historical-player-detail-row, -.historical-player-row:focus-within + .historical-player-detail-row, -.historical-player-detail-row:hover, -.historical-player-detail-row:focus-within, .historical-player-detail-row.is-open { display: table-row; } diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index 5f67703..74a0d63 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -230,9 +230,6 @@ function renderPlayerRows(player, item, index) {
+ - From f54ba4dbd3b93cb2385f6d45569ed7626604021d Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 21 May 2026 11:00:50 +0200 Subject: [PATCH 11/39] feat: refine team filters and epic profile links --- backend/app/player_external_profiles.py | 28 ++- .../css/historico-scoreboard-detail.css | 160 ++++++++++++++++++ frontend/assets/js/historico-partida.js | 41 +++-- frontend/historico-partida.html | 41 +++-- 4 files changed, 239 insertions(+), 31 deletions(-) diff --git a/backend/app/player_external_profiles.py b/backend/app/player_external_profiles.py index 27a47f7..032f57e 100644 --- a/backend/app/player_external_profiles.py +++ b/backend/app/player_external_profiles.py @@ -14,7 +14,8 @@ def build_external_player_profile_fields( player_id: object = None, steam_id: object = None, ) -> dict[str, object]: - """Expose player profile links only when a captured SteamID64 is valid.""" + """Expose external profile links only when a captured identifier is safe.""" + steam_id_64 = normalize_steam_id_64(steam_id) or normalize_steam_id_64(player_id) if steam_id_64: return { @@ -24,9 +25,25 @@ def build_external_player_profile_fields( "steam": f"https://steamcommunity.com/profiles/{steam_id_64}", "hellor": f"https://hellor.pro/player/{steam_id_64}", "hll_records": f"https://hllrecords.com/profiles/{steam_id_64}", + "helo": f"https://helo-system.de/statistics/players/{steam_id_64}?series=2024", }, } - return {"platform": infer_player_platform(player_id=player_id, steam_id=steam_id)} + + epic_id = normalize_epic_id(player_id) + if epic_id: + return { + "epic_id": epic_id, + "platform": "epic", + "external_profile_links": { + "hellor": f"https://hellor.pro/player/{epic_id}", + "hll_records": f"https://hllrecords.com/profiles/{epic_id}", + }, + } + + return { + "platform": infer_player_platform(player_id=player_id, steam_id=steam_id), + "external_profile_links": {}, + } def normalize_steam_id_64(value: object) -> str | None: @@ -34,10 +51,15 @@ def normalize_steam_id_64(value: object) -> str | None: return normalized if _STEAM_ID64_RE.fullmatch(normalized) else None +def normalize_epic_id(value: object) -> str | None: + normalized = str(value or "").strip() + return normalized.lower() if _EPIC_ID_RE.fullmatch(normalized) else None + + def infer_player_platform(*, player_id: object = None, steam_id: object = None) -> str: normalized_player_id = str(player_id or "").strip() if normalize_steam_id_64(steam_id) or normalize_steam_id_64(normalized_player_id): return "steam" - if _EPIC_ID_RE.fullmatch(normalized_player_id): + if normalize_epic_id(normalized_player_id): return "epic" return "unknown" diff --git a/frontend/assets/css/historico-scoreboard-detail.css b/frontend/assets/css/historico-scoreboard-detail.css index 8b9c179..180074f 100644 --- a/frontend/assets/css/historico-scoreboard-detail.css +++ b/frontend/assets/css/historico-scoreboard-detail.css @@ -590,3 +590,163 @@ height: 96px; } } + + +.historical-player-control--team-toggle { + border: 0; + margin: 0; + padding: 0; +} + +.historical-player-control--team-toggle legend { + color: var(--muted); + display: block; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.14em; + margin-bottom: 0.45rem; + text-transform: uppercase; +} + +.historical-player-team-toggle { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.historical-player-team-toggle label { + cursor: pointer; +} + +.historical-player-team-toggle input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.historical-player-team-toggle span { + border: 1px solid rgba(210, 196, 130, 0.34); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.08em; + padding: 0.55rem 0.8rem; + text-transform: uppercase; +} + +.historical-player-team-toggle input:checked + span { + background: rgba(210, 196, 130, 0.12); + border-color: rgba(210, 196, 130, 0.72); + color: var(--text); +} + +.historical-player-team-toggle input:focus-visible + span { + outline: 2px solid rgba(210, 196, 130, 0.9); + outline-offset: 2px; +} + +.historical-player-stats-panel__profiles code { + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 0.82rem; + word-break: break-all; +} + + +/* Player controls refinement: team selector above search/sort. */ +.historical-player-control--team-toggle { + border: 0; + grid-column: 1 / -1; + margin: 0; + order: -1; + padding: 0; +} + +.historical-player-control--team-toggle legend { + color: var(--muted); + display: block; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.14em; + margin-bottom: 0.45rem; + text-transform: uppercase; +} + +.historical-player-team-toggle { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.historical-player-team-toggle label { + cursor: pointer; +} + +.historical-player-team-toggle input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.historical-player-team-toggle span { + border: 1px solid rgba(210, 196, 130, 0.34); + border-radius: 999px; + color: var(--muted); + display: inline-flex; + font-size: 0.75rem; + font-weight: 900; + letter-spacing: 0.08em; + padding: 0.58rem 0.9rem; + text-transform: uppercase; + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease, + box-shadow 160ms ease; +} + +.historical-player-team-toggle__item--all input:checked + span { + background: rgba(210, 196, 130, 0.13); + border-color: rgba(210, 196, 130, 0.78); + color: var(--text); + box-shadow: 0 0 0 1px rgba(210, 196, 130, 0.18); +} + +.historical-player-team-toggle__item--allies span { + border-color: rgba(109, 171, 255, 0.42); + color: #b8d7ff; +} + +.historical-player-team-toggle__item--allies input:checked + span { + background: rgba(72, 135, 220, 0.24); + border-color: rgba(133, 190, 255, 0.9); + color: #e4f1ff; + box-shadow: 0 0 0 1px rgba(109, 171, 255, 0.22); +} + +.historical-player-team-toggle__item--axis span { + border-color: rgba(225, 113, 86, 0.48); + color: #ffb6a6; +} + +.historical-player-team-toggle__item--axis input:checked + span { + background: rgba(170, 72, 54, 0.28); + border-color: rgba(255, 143, 113, 0.92); + color: #ffe1d9; + box-shadow: 0 0 0 1px rgba(225, 113, 86, 0.24); +} + +.historical-player-team-toggle input:focus-visible + span { + outline: 2px solid rgba(210, 196, 130, 0.9); + outline-offset: 2px; +} + +.historical-player-stats-panel__profiles code { + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 0.82rem; + word-break: break-all; +} diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index 95c6739..25ad79e 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -16,7 +16,7 @@ document.addEventListener("DOMContentLoaded", () => { playersState: document.getElementById("match-detail-players-state"), playerControls: document.getElementById("match-detail-player-controls"), playerSearch: document.getElementById("match-detail-player-search"), - playerTeamFilter: document.getElementById("match-detail-player-team-filter"), + 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"), @@ -227,7 +227,9 @@ function renderPlayerSection(item, nodes) { const renderRows = () => renderPlayerTable(item, players, state, nodes); nodes.playerSearch.value = ""; - nodes.playerTeamFilter.value = state.team; + nodes.playerTeamFilters.forEach((control) => { + control.checked = control.value === state.team; + }); nodes.playerSort.value = state.sort; nodes.playerSortDirection.value = state.direction; bindPlayerTableControls(nodes, state, renderRows); @@ -245,11 +247,13 @@ function bindPlayerTableControls(nodes, state, renderRows) { state.search = nodes.playerSearch.value; renderRows(); }; - nodes.playerTeamFilter.onchange = () => { - closePlayerDetailRows(nodes.playersBody); - state.team = nodes.playerTeamFilter.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; @@ -483,10 +487,10 @@ function renderPlayerStatsPanel(player, item, context) {

${escapeHtml(context.playerName)}

- ${renderPlayerStatChip("K", formatOptionalNumber(player.kills))} - ${renderPlayerStatChip("D", formatOptionalNumber(player.deaths))} + ${renderPlayerStatChip("Kills", formatOptionalNumber(player.kills))} + ${renderPlayerStatChip("Muertes", formatOptionalNumber(player.deaths))} ${renderPlayerStatChip("TK", formatOptionalNumber(player.teamkills))} - ${renderPlayerStatChip("K/D", formatKdRatio(player))} + ${renderPlayerStatChip("KD", formatKdRatio(player))} ${renderPlayerStatChip("KPM", context.kpm)}
@@ -512,6 +516,7 @@ function renderExternalProfilesSection(player) { ["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()); @@ -534,12 +539,26 @@ function renderExternalProfilesSection(player) { .join("")} ` - : "

Perfiles externos no disponibles.

" + : renderExternalProfilesUnavailable(player) } `; } + +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 + ? `

Jugador detectado como Epic. ID capturado: ${escapeHtml(epicId)}. Sin enlaces externos compatibles confirmados para este proveedor.

` + : "

Jugador detectado como Epic. Sin enlaces externos compatibles confirmados para este proveedor.

"; + } + + return "

Perfiles externos no disponibles.

"; +} + function renderPlayerStatChip(label, value) { return `
diff --git a/frontend/historico-partida.html b/frontend/historico-partida.html index 55ee7f9..ab4833f 100644 --- a/frontend/historico-partida.html +++ b/frontend/historico-partida.html @@ -94,6 +94,23 @@ aria-label="Controles de jugadores" hidden > +
+ Equipo +
+ + + +
+
-
`; @@ -458,28 +457,12 @@ function renderQuickFacts(items) { `; } -function getServerHistoryUrl(server) { - if (typeof server?.community_history_url === "string" && server.community_history_url.trim()) { - return server.community_history_url.trim(); - } - +function getTrustedServerActions(server) { const externalServerId = typeof server?.external_server_id === "string" ? server.external_server_id.trim() : ""; - if (!externalServerId) { - return ""; - } - - return SERVER_HISTORY_URLS_FALLBACK[externalServerId] || ""; -} - -function getServerHistoryState(server) { - const historyUrl = getServerHistoryUrl(server); - return { - available: Boolean(historyUrl), - url: historyUrl, - }; + return TRUSTED_SERVER_ACTIONS[externalServerId] || null; } function selectPrimaryServerItems(items) { diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js new file mode 100644 index 0000000..3b2f8c7 --- /dev/null +++ b/frontend/assets/js/partida-actual.js @@ -0,0 +1,256 @@ +const CURRENT_MATCH_POLL_INTERVAL_MS = 30 * 1000; +const CURRENT_MATCH_SERVERS = Object.freeze({ + "comunidad-hispana-01": "Comunidad Hispana #01", + "comunidad-hispana-02": "Comunidad Hispana #02", +}); + +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"), + }; + 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); + let refreshInFlight = false; + const refresh = async () => { + if (refreshInFlight) { + return; + } + refreshInFlight = true; + try { + await Promise.allSettled([ + loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }), + loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }), + ]); + } finally { + refreshInFlight = false; + } + }; + + void refresh(); + window.setInterval(() => { + void refresh(); + }, CURRENT_MATCH_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 payload = await fetchJson( + `${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=30`, + ); + renderKillFeed(payload?.data || {}, nodes, killFeedState); + } catch (error) { + setState(nodes.feedState, "No se pudo actualizar el feed de combate.", true); + } +} + +function renderCurrentMatch(data, nodes) { + const serverName = data.server_name || data.server_slug || "Servidor no disponible"; + const mapName = data.map || "Mapa no disponible"; + nodes.title.textContent = serverName; + nodes.summary.textContent = mapName; + nodes.note.textContent = data.found + ? "Snapshot en vivo recibido. La pagina se actualiza cada 30 segundos." + : "Todavia no hay snapshot live disponible para este servidor."; + nodes.scoreboard.href = data.public_scoreboard_url; + nodes.scoreboard.hidden = !data.public_scoreboard_url; + nodes.grid.innerHTML = [ + renderStat("Estado", formatStatus(data.status)), + renderStat("Mapa", mapName), + renderStat("Modo", data.game_mode || "No disponible"), + renderStat("Inicio", formatTimestamp(data.started_at)), + renderStat("Jugadores", formatPlayers(data.players, data.max_players)), + renderStat("Marcador aliado", formatScore(data.allied_score)), + renderStat("Marcador eje", formatScore(data.axis_score)), + renderStat("Actualizado", formatTimestamp(data.captured_at || data.updated_at)), + ].join(""); + 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; + setState(nodes.state, "No se puede consultar la partida solicitada.", true); +} + +function initializeKillFeed(nodes) { + const feedShell = nodes.feedTitle?.closest(".panel__shell"); + if (feedShell) { + feedShell.insertAdjacentHTML( + "beforeend", + ` +

+ Cargando feed de combate... +

+
+ `, + ); + } + nodes.feedState = document.getElementById("current-match-feed-state"); + nodes.feedList = document.getElementById("current-match-feed-list"); + return { + byId: new Map(), + }; +} + +function renderKillFeed(data, nodes, state) { + const incoming = Array.isArray(data.items) ? data.items : []; + incoming.forEach((event) => { + if (event?.event_id) { + state.byId.set(event.event_id, event); + } + }); + const events = [...state.byId.values()].sort(compareKillFeedEvents).slice(0, 30); + if (events.length === 0) { + nodes.feedList.innerHTML = ""; + setState(nodes.feedState, "Todavia no se han detectado bajas en esta partida."); + return; + } + nodes.feedList.innerHTML = events.map(renderKillFeedRow).join(""); + nodes.feedState.textContent = + data.scope === "open-admin-log-match-window" + ? "Bajas del tramo abierto detectado por AdminLog." + : "Bajas recientes de AdminLog con cobertura parcial."; + nodes.feedState.classList.remove("historical-state--error"); +} + +function compareKillFeedEvents(left, right) { + const rightTime = Number(right.server_time); + const leftTime = Number(left.server_time); + if (Number.isFinite(rightTime) && Number.isFinite(leftTime) && rightTime !== leftTime) { + return rightTime - leftTime; + } + return String(right.event_timestamp || "").localeCompare(String(left.event_timestamp || "")); +} + +function renderKillFeedRow(event) { + const teamkillBadge = event.is_teamkill + ? 'TK' + : ""; + const eventTime = formatEventTime(event); + return ` +
+
+
+

${escapeHtml(eventTime)}

+ ${escapeHtml(event.killer_name || "Jugador no disponible")} +
+ ${teamkillBadge} +
+

+ ${escapeHtml(event.weapon || "Arma no disponible")} + -> ${escapeHtml(event.victim_name || "Objetivo no disponible")} +

+
+ `; +} + +function formatEventTime(event) { + const timestamp = formatTimestamp(event.event_timestamp); + if (timestamp !== "No disponible") { + return timestamp; + } + return Number.isFinite(Number(event.server_time)) + ? `Tiempo servidor ${Number(event.server_time)}` + : "Tiempo no disponible"; +} + +function renderStat(label, value) { + return ` +
+

${escapeHtml(label)}

+ ${escapeHtml(value)} +
+ `; +} + +function formatStatus(value) { + if (value === "online") { + return "Online"; + } + if (value === "offline") { + return "Offline"; + } + return "No disponible"; +} + +function formatPlayers(players, maxPlayers) { + if (!Number.isFinite(Number(players)) || !Number.isFinite(Number(maxPlayers))) { + return "No disponible"; + } + return `${Number(players)} / ${Number(maxPlayers)}`; +} + +function formatScore(value) { + return Number.isFinite(Number(value)) ? String(Number(value)) : "No disponible"; +} + +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 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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/frontend/partida-actual.html b/frontend/partida-actual.html new file mode 100644 index 0000000..4d73dc2 --- /dev/null +++ b/frontend/partida-actual.html @@ -0,0 +1,115 @@ + + + + + + + Partida actual - HLL Vietnam + + + + +
+
+
+
+
+ + VOLVER INICIO + +

Partida en vivo

+
+
+
+ Logo oficial de la comunidad HLL Vietnam +
+
+
+

+ Partida actual +

+

+ Cargando estado del servidor. +

+
+ +
+
+
+
+ +
+
+
+
+
+

Estado en vivo

+

Marcador en curso

+

+ Leyendo el ultimo snapshot disponible. +

+
+
+

+ Cargando partida actual... +

+ +
+
+ +
+
+
+
+

Combate

+

Feed de combate

+

+ Los eventos de bajas en vivo apareceran aqui cuando se habiliten. +

+
+
+
+
+ +
+
+
+
+

Jugadores

+

Estadisticas en vivo

+

+ Las estadisticas en vivo apareceran cuando haya datos suficientes. +

+
+
+
+
+
+
+ + + From 45801bb955203e471e2ee135003c6d8925036b6d Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 21 May 2026 16:39:18 +0200 Subject: [PATCH 16/39] fix: project current match live data safely --- ...K-153-fix-current-match-live-projection.md | 110 +++++++++ backend/app/payloads.py | 121 +++++++++ backend/app/rcon_admin_log_storage.py | 45 ++++ backend/tests/test_current_match_payload.py | 118 +++++++++ backend/tests/test_rcon_admin_log_storage.py | 59 +++++ frontend/assets/css/historico.css | 41 ++++ frontend/assets/js/partida-actual.js | 230 +++++++++++++++--- frontend/partida-actual.html | 20 +- 8 files changed, 715 insertions(+), 29 deletions(-) create mode 100644 ai/tasks/done/TASK-153-fix-current-match-live-projection.md create mode 100644 backend/tests/test_current_match_payload.py diff --git a/ai/tasks/done/TASK-153-fix-current-match-live-projection.md b/ai/tasks/done/TASK-153-fix-current-match-live-projection.md new file mode 100644 index 0000000..3df8aa1 --- /dev/null +++ b/ai/tasks/done/TASK-153-fix-current-match-live-projection.md @@ -0,0 +1,110 @@ +--- +id: TASK-153 +title: Fix current match live projection +status: done +type: backend +team: Backend Senior +supporting_teams: + - Frontend Senior +roadmap_item: rcon-full-data +priority: high +--- + +# TASK-153 - Fix current match live projection + +## Goal + +Project trusted live RCON match data onto the current-match page without +misrepresenting player population or stale AdminLog kills as current activity. + +## Context + +`/api/current-match` currently reads through `/api/servers`. The direct RCON +sample already contains `game_mode` and scores, but the server snapshot +projection drops those richer live fields. Direct RCON `playerCount` is also +currently reporting `0` while manual public scoreboard observation shows `1`, +so that population value must be labeled unverified instead of shown as a +confident live count. + +## Steps + +1. Inspect the listed current-match, live RCON and AdminLog files first. +2. Keep the trusted current scoreboard URL mapping and unsupported-server + guards intact. +3. Rework the live payload, kill-feed freshness handling and current-match UI. +4. Add focused backend tests and validate the frontend JavaScript. + +## Files to Read First + +- `AGENTS.md` +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `backend/app/payloads.py` +- `backend/app/rcon_client.py` +- `frontend/assets/js/partida-actual.js` + +## Expected Files to Modify + +- `backend/app/payloads.py` +- `backend/app/rcon_admin_log_storage.py` +- `backend/tests/test_rcon_admin_log_storage.py` +- `backend/tests/test_current_match_payload.py` +- `frontend/partida-actual.html` +- `frontend/assets/js/partida-actual.js` +- current-match styling only where needed + +## Constraints + +- Do not break historical pages or scoreboard correlation logic. +- Do not depend on Comunidad Hispana #03. +- Do not fabricate live data or expose arbitrary public URLs. +- Keep current scoreboard buttons on trusted base URLs without `/games`. +- Preserve null versus explicit zero semantics. +- Do not display closed-match fields or stale kill rows as live data. + +## Validation + +- `python -m compileall backend/app` +- Run focused backend tests for current-match payload and AdminLog kill feed. +- `node --check frontend/assets/js/partida-actual.js` +- Review `git diff --name-only` against this task scope. + +## Outcome + +- `/api/current-match` now queries the requested configured trusted RCON target + for the richer session projection first and falls back to the generic live + server snapshot only when direct RCON data is unavailable. +- RCA: direct RCON `GetServerInformation` samples contain `game_mode`, scores, + map ids and match timing fields. The prior current-match API lost the game + mode and score because it projected through `/api/servers`, whose snapshot + shape keeps only the server-card live fields. +- RCA: live validation on May 21, 2026 still returned RCON `playerCount: 0` + while manual scoreboard observation for the same servers had shown `1`. + Current-match payloads therefore expose RCON population with + `player_count_quality: "rcon-session-unverified"` and the frontend renders + that as `No verificado` instead of a confident `0 / 100`. +- The live page now mirrors the historical detail layout direction: map title, + server context, map art or a clean placeholder, a large in-progress + scoreboard, live metadata only, and trusted base scoreboard buttons. +- AdminLog current-match fallback kills are capped to a conservative 15-minute + freshness window. Old fallback rows now return + `scope: "no-current-match-events"` with stale-filter metadata instead of + leaking into the live feed. +- Added focused tests for direct current-match payload projection and AdminLog + fallback freshness. The repository environment and the rebuilt backend image + do not install `pytest`, so the focused test files could not be executed via + `python -m pytest`; the same payload and freshness paths were exercised with + narrow inline Python scenarios. +- Validation: `python -m compileall backend/app`; + `node --check frontend/assets/js/partida-actual.js`; + `git diff --check`; + `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`; + rebuilt Compose `backend` and `frontend`; REST checks for both trusted + `/api/current-match` endpoints and both kill-feed endpoints; served map asset + checks for Carentan and St. Marie Du Mont; unsupported current-match server + REST check returned 404. + +## Change Budget + +The requested projection spans backend, tests and the live page. Keep changes +focused on current-match files and split unrelated work out. diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 68bd7b9..de029a8 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -49,6 +49,7 @@ from .historical_storage import ( ) from .rcon_historical_read_model import get_rcon_historical_match_detail from .normalizers import normalize_map_name +from .rcon_client import load_rcon_targets, query_live_server_sample from .rcon_admin_log_storage import list_current_match_kill_feed from .scoreboard_origins import get_trusted_public_scoreboard_origin from .storage import list_latest_snapshots, list_server_history, list_snapshot_history @@ -244,6 +245,61 @@ def build_current_match_payload(*, server_slug: str) -> dict[str, object]: if origin is None: raise ValueError("Unsupported current match server.") + sample = _query_current_match_rcon_sample(origin.slug) + if sample is not None: + normalized = sample["normalized"] + raw_session = sample["raw_session"] + captured_at = _utc_timestamp_now() + map_id = raw_session.get("mapId") or normalized.get("current_map") + map_name = raw_session.get("mapName") or map_id + map_pretty_name = normalize_map_name(map_name) + return { + "status": "ok", + "data": { + "found": True, + "server_slug": origin.slug, + "server_name": normalized.get("server_name") or origin.display_name, + "status": normalized.get("status") or "unavailable", + "map": map_pretty_name, + "map_id": map_id, + "map_pretty_name": map_pretty_name, + "game_mode": normalized.get("game_mode"), + "started_at": None, + "allied_score": normalized.get("allied_score"), + "axis_score": normalized.get("axis_score"), + "allied_players": normalized.get("allied_players"), + "axis_players": normalized.get("axis_players"), + "players": normalized.get("players"), + "max_players": normalized.get("max_players"), + # RCA: getSession currently reports 0 while the public scoreboard + # can show players, so session population is exposed but unverified. + "player_count_quality": ( + "rcon-session-unverified" + if normalized.get("players") is not None + else None + ), + "player_count_source": _source_when_present( + normalized.get("players"), + source="rcon-session", + ), + "score_source": _source_when_present( + normalized.get("allied_score"), + normalized.get("axis_score"), + source="rcon-session", + ), + "map_source": _source_when_present(map_id, map_name, source="rcon-session"), + "match_time_seconds": normalized.get("match_time_seconds"), + "remaining_match_time_seconds": normalized.get( + "remaining_match_time_seconds" + ), + "captured_at": captured_at, + "updated_at": captured_at, + "public_scoreboard_url": origin.base_url, + }, + } + + # The generic live server snapshot is a fallback only. It intentionally + # drops richer RCON session fields such as game mode and current scores. server_payload = build_servers_payload() server_data = server_payload["data"] item = next( @@ -262,12 +318,31 @@ def build_current_match_payload(*, server_slug: str) -> dict[str, object]: "server_name": item.get("server_name") if item else origin.display_name, "status": item.get("status") if item else "unavailable", "map": item.get("current_map") if item else None, + "map_id": None, + "map_pretty_name": item.get("current_map") if item else None, "game_mode": item.get("game_mode") if item else None, "started_at": item.get("started_at") if item else None, "allied_score": item.get("allied_score") if item else None, "axis_score": item.get("axis_score") if item else None, + "allied_players": item.get("allied_players") if item else None, + "axis_players": item.get("axis_players") if item else None, "players": item.get("players") if item else None, "max_players": item.get("max_players") if item else None, + "player_count_quality": _snapshot_player_count_quality(item), + "player_count_source": _snapshot_player_count_source(item), + "score_source": _source_when_present( + item.get("allied_score") if item else None, + item.get("axis_score") if item else None, + source="live-server-snapshot", + ), + "map_source": _source_when_present( + item.get("current_map") if item else None, + source="live-server-snapshot", + ), + "match_time_seconds": item.get("match_time_seconds") if item else None, + "remaining_match_time_seconds": ( + item.get("remaining_match_time_seconds") if item else None + ), "captured_at": item.get("captured_at") if item else None, "updated_at": server_data.get("last_snapshot_at"), "public_scoreboard_url": origin.base_url, @@ -295,6 +370,52 @@ def build_current_match_kill_feed_payload( } +def _query_current_match_rcon_sample(server_slug: str) -> dict[str, object] | None: + """Read one configured trusted RCON target for the current-match view.""" + try: + targets = load_rcon_targets() + except (RuntimeError, ValueError): + return None + target = next( + (candidate for candidate in targets if candidate.external_server_id == server_slug), + None, + ) + if target is None: + return None + try: + return query_live_server_sample(target) + except Exception: # noqa: BLE001 - fall back to the existing live snapshot read + return None + + +def _utc_timestamp_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _source_when_present(*values: object, source: str) -> str | None: + return source if any(value is not None for value in values) else None + + +def _snapshot_player_count_quality(item: dict[str, object] | None) -> str | None: + if item is None or item.get("players") is None: + return None + if item.get("snapshot_origin") == "real-rcon": + return "rcon-session-unverified" + if item.get("snapshot_origin") == "real-a2s": + return "a2s-query" + return "snapshot-unverified" + + +def _snapshot_player_count_source(item: dict[str, object] | None) -> str | None: + if item is None or item.get("players") is None: + return None + if item.get("snapshot_origin") == "real-rcon": + return "rcon-session" + if item.get("snapshot_origin") == "real-a2s": + return "a2s" + return "live-server-snapshot" + + def build_error_payload(message: str) -> dict[str, str]: """Return the shared error payload shape used by the backend bootstrap.""" return { diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py index 6672f54..3c4a99b 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -7,6 +7,7 @@ import re import sqlite3 from collections.abc import Mapping from contextlib import closing +from datetime import datetime, timedelta, timezone from pathlib import Path from .config import get_storage_path, use_postgres_rcon_storage @@ -15,6 +16,8 @@ from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot from .rcon_historical_storage import initialize_rcon_historical_storage from .sqlite_utils import connect_sqlite_writer +CURRENT_MATCH_FALLBACK_FRESHNESS = timedelta(minutes=15) + def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path: """Create SQLite structures for parsed RCON AdminLog events.""" @@ -334,6 +337,7 @@ def list_current_match_kill_feed( server_key: str, limit: int = 30, db_path: Path | None = None, + now: datetime | None = None, ) -> dict[str, object]: """Return safe recent kill rows for one AdminLog server window.""" resolved_path = initialize_rcon_admin_log_storage(db_path=db_path) @@ -396,9 +400,24 @@ def list_current_match_kill_feed( scope = "open-admin-log-match-window" confidence = "admin-log-boundary" + stale_events_filtered = 0 + if scope == "recent-admin-log-window": + freshness_anchor = _as_utc_datetime(now) or datetime.now(timezone.utc) + fresh_rows = [ + row + for row in rows + if _row_is_current_match_fallback_fresh(row, freshness_anchor) + ] + stale_events_filtered = len(rows) - len(fresh_rows) + rows = fresh_rows + if not rows: + scope = "no-current-match-events" + confidence = "stale-filtered" if stale_events_filtered else "none" + return { "scope": scope, "confidence": confidence, + "stale_events_filtered": stale_events_filtered, "items": [_serialize_kill_feed_row(row) for row in rows], } @@ -524,6 +543,32 @@ def _safe_event_field(value: object) -> str | None: return normalized or None +def _row_is_current_match_fallback_fresh( + row: Mapping[str, object], + freshness_anchor: datetime, +) -> bool: + event_time = _as_utc_datetime(row["event_timestamp"]) + if event_time is None: + return False + age = freshness_anchor - event_time + return timedelta(0) <= age <= CURRENT_MATCH_FALLBACK_FRESHNESS + + +def _as_utc_datetime(value: object) -> datetime | None: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + else: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + def _persist_rcon_admin_log_entries_postgres( *, target: Mapping[str, object], diff --git a/backend/tests/test_current_match_payload.py b/backend/tests/test_current_match_payload.py new file mode 100644 index 0000000..8798387 --- /dev/null +++ b/backend/tests/test_current_match_payload.py @@ -0,0 +1,118 @@ +from http import HTTPStatus +from unittest.mock import patch + +from app.payloads import build_current_match_payload +from app.rcon_client import RconServerTarget +from app.routes import resolve_get_payload + + +TARGET = RconServerTarget( + name="Comunidad Hispana #01", + host="127.0.0.1", + port=7779, + password="test-password", + source_name="test-rcon", + external_server_id="comunidad-hispana-01", +) + + +def test_current_match_payload_projects_rich_live_rcon_session_fields(): + data = _build_with_rcon_sample( + { + "normalized": { + "server_name": "Comunidad Hispana #01", + "status": "online", + "current_map": "carentan_warfare", + "game_mode": "Warfare", + "allied_score": 2, + "axis_score": 2, + "allied_players": 0, + "axis_players": 0, + "players": 0, + "max_players": 100, + "match_time_seconds": 5400, + "remaining_match_time_seconds": 0, + }, + "raw_session": {"mapId": "carentan_warfare", "mapName": "CARENTAN"}, + } + ) + + assert data["map"] == "Carentan" + assert data["map_id"] == "carentan_warfare" + assert data["map_pretty_name"] == "Carentan" + assert data["game_mode"] == "Warfare" + assert data["allied_score"] == 2 + assert data["axis_score"] == 2 + assert data["players"] == 0 + assert data["player_count_quality"] == "rcon-session-unverified" + assert data["player_count_source"] == "rcon-session" + assert data["score_source"] == "rcon-session" + assert data["map_source"] == "rcon-session" + assert data["public_scoreboard_url"] == "https://scoreboard.comunidadhll.es" + assert "/games" not in data["public_scoreboard_url"] + + +def test_current_match_payload_preserves_missing_values_as_null(): + data = _build_with_rcon_sample( + { + "normalized": { + "server_name": "Comunidad Hispana #01", + "status": "online", + "current_map": None, + "game_mode": None, + "players": None, + "max_players": None, + }, + "raw_session": {}, + } + ) + + assert data["map"] is None + assert data["map_id"] is None + assert data["game_mode"] is None + assert data["allied_score"] is None + assert data["axis_score"] is None + assert data["players"] is None + assert data["player_count_quality"] is None + assert data["player_count_source"] is None + assert data["score_source"] is None + assert data["map_source"] is None + + +def test_current_match_payload_keeps_explicit_zero_score(): + data = _build_with_rcon_sample( + { + "normalized": { + "server_name": "Comunidad Hispana #01", + "status": "online", + "current_map": "stmariedumont_warfare", + "allied_score": 0, + "axis_score": 0, + }, + "raw_session": { + "mapId": "stmariedumont_warfare", + "mapName": "ST MARIE DU MONT", + }, + } + ) + + assert data["map"] == "St. Marie Du Mont" + assert data["allied_score"] == 0 + assert data["axis_score"] == 0 + assert data["score_source"] == "rcon-session" + + +def test_current_match_route_rejects_unsupported_server(): + status, payload = resolve_get_payload("/api/current-match?server=not-trusted") + + assert status == HTTPStatus.NOT_FOUND + assert payload["status"] == "error" + + +def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]: + with ( + patch("app.payloads.load_rcon_targets", return_value=(TARGET,)), + patch("app.payloads.query_live_server_sample", return_value=sample), + ): + payload = build_current_match_payload(server_slug="comunidad-hispana-01") + return payload["data"] diff --git a/backend/tests/test_rcon_admin_log_storage.py b/backend/tests/test_rcon_admin_log_storage.py index 8848a81..4b44fc9 100644 --- a/backend/tests/test_rcon_admin_log_storage.py +++ b/backend/tests/test_rcon_admin_log_storage.py @@ -1,6 +1,7 @@ import gc import json import sqlite3 +from datetime import datetime, timezone from app.rcon_admin_log_storage import ( initialize_rcon_admin_log_storage, @@ -309,3 +310,61 @@ def test_current_match_kill_feed_prefers_open_match_window_and_normalizes_rows(t "is_teamkill": True, } gc.collect() + + +def test_current_match_kill_feed_filters_stale_recent_fallback_rows(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-21T09:30:00Z", + "message": ( + "[1:00 min (1779355800)] KILL: Old Killer(Allies/steam-old) -> " + "Old Victim(Axis/steam-victim-old) with M1 GARAND" + ), + } + ], + db_path=db_path, + ) + + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc), + ) + + assert feed["scope"] == "no-current-match-events" + assert feed["confidence"] == "stale-filtered" + assert feed["stale_events_filtered"] == 1 + assert feed["items"] == [] + gc.collect() + + +def test_current_match_kill_feed_marks_fresh_recent_fallback_rows_partial(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-21T09:50:00Z", + "message": ( + "[1:00 min (1779357000)] KILL: Fresh Killer(Allies/steam-fresh) -> " + "Fresh Victim(Axis/steam-victim-fresh) with M1 GARAND" + ), + } + ], + db_path=db_path, + ) + + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc), + ) + + assert feed["scope"] == "recent-admin-log-window" + assert feed["confidence"] == "partial" + assert feed["stale_events_filtered"] == 0 + assert [item["killer_name"] for item in feed["items"]] == ["Fresh Killer"] + gc.collect() diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index 98e3157..52e26b6 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -88,6 +88,47 @@ object-fit: cover; } +.current-match-map-hero { + display: grid; + isolation: isolate; +} + +.current-match-map-hero > * { + grid-area: 1 / 1; +} + +.current-match-map-placeholder { + z-index: 0; + display: grid; + align-content: end; + gap: 6px; + min-height: 220px; + padding: 24px; + color: var(--text); + background: + linear-gradient(135deg, rgba(183, 201, 125, 0.12), transparent 44%), + repeating-linear-gradient( + -28deg, + rgba(210, 182, 118, 0.06) 0 1px, + transparent 1px 20px + ); +} + +.current-match-map-placeholder strong { + font-size: 1.16rem; + text-transform: uppercase; +} + +.current-match-map-placeholder span { + color: var(--text-soft); +} + +.current-match-scoreboard-message { + max-width: 12ch; + font-size: clamp(2rem, 4vw, 3.3rem); + line-height: 1; +} + .historical-content { gap: 22px; } diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js index 3b2f8c7..608a69e 100644 --- a/frontend/assets/js/partida-actual.js +++ b/frontend/assets/js/partida-actual.js @@ -3,6 +3,10 @@ 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", +}); document.addEventListener("DOMContentLoaded", () => { const params = new URLSearchParams(window.location.search); @@ -16,6 +20,9 @@ document.addEventListener("DOMContentLoaded", () => { state: document.getElementById("current-match-state"), grid: document.getElementById("current-match-grid"), feedTitle: document.getElementById("current-match-feed-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"; @@ -74,24 +81,17 @@ async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState } function renderCurrentMatch(data, nodes) { const serverName = data.server_name || data.server_slug || "Servidor no disponible"; - const mapName = data.map || "Mapa no disponible"; - nodes.title.textContent = serverName; - nodes.summary.textContent = mapName; + 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 - ? "Snapshot en vivo recibido. La pagina se actualiza cada 30 segundos." + ? "Lectura en vivo recibida. La pagina se actualiza cada 30 segundos." : "Todavia no hay snapshot live disponible para este servidor."; - nodes.scoreboard.href = data.public_scoreboard_url; - nodes.scoreboard.hidden = !data.public_scoreboard_url; - nodes.grid.innerHTML = [ - renderStat("Estado", formatStatus(data.status)), - renderStat("Mapa", mapName), - renderStat("Modo", data.game_mode || "No disponible"), - renderStat("Inicio", formatTimestamp(data.started_at)), - renderStat("Jugadores", formatPlayers(data.players, data.max_players)), - renderStat("Marcador aliado", formatScore(data.allied_score)), - renderStat("Marcador eje", formatScore(data.axis_score)), - renderStat("Actualizado", formatTimestamp(data.captured_at || data.updated_at)), - ].join(""); + 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; } @@ -103,6 +103,7 @@ function renderUnsupportedServer(nodes) { 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); } @@ -128,6 +129,9 @@ function initializeKillFeed(nodes) { function renderKillFeed(data, nodes, state) { const incoming = Array.isArray(data.items) ? data.items : []; + if (data.scope === "no-current-match-events") { + state.byId.clear(); + } incoming.forEach((event) => { if (event?.event_id) { state.byId.set(event.event_id, event); @@ -140,10 +144,7 @@ function renderKillFeed(data, nodes, state) { return; } nodes.feedList.innerHTML = events.map(renderKillFeedRow).join(""); - nodes.feedState.textContent = - data.scope === "open-admin-log-match-window" - ? "Bajas del tramo abierto detectado por AdminLog." - : "Bajas recientes de AdminLog con cobertura parcial."; + nodes.feedState.textContent = formatKillFeedCoverage(data.scope); nodes.feedState.classList.remove("historical-state--error"); } @@ -188,10 +189,10 @@ function formatEventTime(event) { : "Tiempo no disponible"; } -function renderStat(label, value) { +function renderCompactMeta(label, value) { return ` -
-

${escapeHtml(label)}

+
+ ${escapeHtml(label)} ${escapeHtml(value)}
`; @@ -208,16 +209,12 @@ function formatStatus(value) { } function formatPlayers(players, maxPlayers) { - if (!Number.isFinite(Number(players)) || !Number.isFinite(Number(maxPlayers))) { + if (!isNumericValue(players) || !isNumericValue(maxPlayers)) { return "No disponible"; } return `${Number(players)} / ${Number(maxPlayers)}`; } -function formatScore(value) { - return Number.isFinite(Number(value)) ? String(Number(value)) : "No disponible"; -} - function formatTimestamp(value) { if (!value) { return "No disponible"; @@ -232,6 +229,183 @@ function formatTimestamp(value) { }).format(timestamp); } +function renderLiveScoreboard(data, { mapName, serverName }) { + const scoreKnown = hasKnownScore(data); + const scoreMarkup = scoreKnown + ? `${Number(data.allied_score)} : ${Number(data.axis_score)}` + : "Marcador no disponible"; + const scoreClass = scoreKnown ? "" : " current-match-scoreboard-message"; + const metadata = [ + ["Servidor", serverName], + ["Mapa", mapName], + ["Modo", formatGameMode(data.game_mode)], + ]; + if (data.started_at) { + metadata.push(["Inicio", formatTimestamp(data.started_at)]); + } + const remainingTime = Number(data.remaining_match_time_seconds); + if (Number.isFinite(remainingTime) && remainingTime > 0) { + metadata.push(["Tiempo restante", formatDuration(remainingTime)]); + } + const matchTime = Number(data.match_time_seconds); + if (Number.isFinite(matchTime) && matchTime > 0) { + metadata.push(["Tiempo de partida", formatDuration(matchTime)]); + } + metadata.push(["Jugadores", formatPlayerCount(data)]); + metadata.push(["Actualizado", formatTimestamp(data.captured_at || data.updated_at)]); + + return ` +
+
+ ${renderLiveSide("historical-scoreboard-side--allied", "Aliados", "./assets/img/factions/us.webp")} +
+ ${escapeHtml(formatStatus(data.status))} + ${escapeHtml(scoreMarkup)} + ${escapeHtml(mapName)} + ${escapeHtml(formatGameMode(data.game_mode))} +
+ ${renderLiveSide("historical-scoreboard-side--axis", "Eje", "./assets/img/factions/germany.webp")} +
+
+ ${metadata.map(([label, value]) => renderCompactMeta(label, value)).join("")} +
+
+ `; +} + +function renderLiveSide(sideClass, label, emblem) { + return ` +
+ ${escapeHtml(label)} +
+ ${escapeHtml(label)} +
+
+ `; +} + +function renderMapHero(data, mapName, nodes) { + if (!nodes.mapImage || !nodes.mapPlaceholder) { + return; + } + const mapImagePath = resolveMapImagePath(data, mapName); + nodes.mapPlaceholder.hidden = Boolean(mapImagePath); + nodes.mapImage.hidden = !mapImagePath; + if (!mapImagePath) { + nodes.mapImage.removeAttribute("src"); + nodes.mapImage.alt = ""; + return; + } + nodes.mapImage.src = mapImagePath; + nodes.mapImage.alt = mapName; + nodes.mapImage.onerror = () => { + nodes.mapImage.removeAttribute("src"); + nodes.mapImage.hidden = true; + nodes.mapPlaceholder.hidden = false; + }; +} + +function resolveMapImagePath(data, mapName) { + const 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 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."; + } + return "Todavia 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; diff --git a/frontend/partida-actual.html b/frontend/partida-actual.html index 4d73dc2..ec0d0e4 100644 --- a/frontend/partida-actual.html +++ b/frontend/partida-actual.html @@ -10,6 +10,7 @@ Partida actual - HLL Vietnam +
@@ -58,6 +59,23 @@
+
+ +
+ Mapa en vivo + Esperando imagen disponible. +
+
@@ -88,7 +106,7 @@

Combate

Feed de combate

- Los eventos de bajas en vivo apareceran aqui cuando se habiliten. + Leyendo eventos recientes con cobertura segura para esta partida.

From 2c614982b3947cf97d4f65a6b772a6d44f9f4ff1 Mon Sep 17 00:00:00 2001 From: devRaGonSa <97627393+devRaGonSa@users.noreply.github.com> Date: Thu, 21 May 2026 17:09:40 +0200 Subject: [PATCH 17/39] fix: shorten current match server display name --- frontend/assets/js/partida-actual.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js index 608a69e..b4fae7a 100644 --- a/frontend/assets/js/partida-actual.js +++ b/frontend/assets/js/partida-actual.js @@ -80,7 +80,8 @@ async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState } } function renderCurrentMatch(data, nodes) { - const serverName = data.server_name || data.server_slug || "Servidor no disponible"; + 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; @@ -349,6 +350,21 @@ function resolveTrustedScoreboardUrl(data) { 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); } From 152a4aa3aee4d0a96da0b491fbb7773702643b5a Mon Sep 17 00:00:00 2001 From: devRaGonSa <97627393+devRaGonSa@users.noreply.github.com> Date: Thu, 21 May 2026 17:42:32 +0200 Subject: [PATCH 18/39] fix: rebalance home server card layout --- frontend/index.html | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/frontend/index.html b/frontend/index.html index 2d35b52..b93f5a2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,6 +9,33 @@ /> Comunidad Hispana - HLL Vietnam + - + \ No newline at end of file From 8a6af3e8694bbf489870f62e926a2cc9b2348beb Mon Sep 17 00:00:00 2001 From: devRaGonSa <97627393+devRaGonSa@users.noreply.github.com> Date: Thu, 21 May 2026 18:41:55 +0200 Subject: [PATCH 19/39] fix: align home server action buttons --- frontend/index.html | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/index.html b/frontend/index.html index b93f5a2..356ebdb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,12 +10,37 @@ Comunidad Hispana - HLL Vietnam - \ No newline at end of file + From efa4e5cb16776ac725f4ecfdd4d6e26d49706eee Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 21 May 2026 19:56:57 +0200 Subject: [PATCH 25/39] chore: add weapon icon assets --- frontend/assets/img/weapons/browing_m1919.PNG | Bin 0 -> 22519 bytes frontend/assets/img/weapons/colt_1911.PNG | Bin 0 -> 15114 bytes frontend/assets/img/weapons/flammenwefer41.PNG | Bin 0 -> 25428 bytes frontend/assets/img/weapons/kar98k_x8.PNG | Bin 0 -> 13855 bytes frontend/assets/img/weapons/luger_p08.PNG | Bin 0 -> 17447 bytes .../assets/img/weapons/m1903_springfield.PNG | Bin 0 -> 14780 bytes frontend/assets/img/weapons/m2_flamethrower.PNG | Bin 0 -> 19206 bytes frontend/assets/img/weapons/m3_grease_gun.PNG | Bin 0 -> 17261 bytes frontend/assets/img/weapons/m97.PNG | Bin 0 -> 12194 bytes frontend/assets/img/weapons/mg34.PNG | Bin 0 -> 17971 bytes .../assets/img/weapons/mosing_nagant_1891.PNG | Bin 0 -> 15972 bytes .../assets/img/weapons/mosing_nagant_m38.PNG | Bin 0 -> 15524 bytes frontend/assets/img/weapons/nagant_m1895.PNG | Bin 0 -> 15812 bytes frontend/assets/img/weapons/panzerchreck.PNG | Bin 0 -> 18751 bytes frontend/assets/img/weapons/ppsh_41w_drum.PNG | Bin 0 -> 18145 bytes .../img/weapons/scoped_mosin_nagant_9130.PNG | Bin 0 -> 21907 bytes frontend/assets/img/weapons/scoped_svt40.PNG | Bin 0 -> 19742 bytes frontend/assets/img/weapons/tokarev_tt33.PNG | Bin 0 -> 15343 bytes frontend/assets/img/weapons/walther_p38.PNG | Bin 0 -> 15978 bytes 19 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 frontend/assets/img/weapons/browing_m1919.PNG create mode 100644 frontend/assets/img/weapons/colt_1911.PNG create mode 100644 frontend/assets/img/weapons/flammenwefer41.PNG create mode 100644 frontend/assets/img/weapons/kar98k_x8.PNG create mode 100644 frontend/assets/img/weapons/luger_p08.PNG create mode 100644 frontend/assets/img/weapons/m1903_springfield.PNG create mode 100644 frontend/assets/img/weapons/m2_flamethrower.PNG create mode 100644 frontend/assets/img/weapons/m3_grease_gun.PNG create mode 100644 frontend/assets/img/weapons/m97.PNG create mode 100644 frontend/assets/img/weapons/mg34.PNG create mode 100644 frontend/assets/img/weapons/mosing_nagant_1891.PNG create mode 100644 frontend/assets/img/weapons/mosing_nagant_m38.PNG create mode 100644 frontend/assets/img/weapons/nagant_m1895.PNG create mode 100644 frontend/assets/img/weapons/panzerchreck.PNG create mode 100644 frontend/assets/img/weapons/ppsh_41w_drum.PNG create mode 100644 frontend/assets/img/weapons/scoped_mosin_nagant_9130.PNG create mode 100644 frontend/assets/img/weapons/scoped_svt40.PNG create mode 100644 frontend/assets/img/weapons/tokarev_tt33.PNG create mode 100644 frontend/assets/img/weapons/walther_p38.PNG diff --git a/frontend/assets/img/weapons/browing_m1919.PNG b/frontend/assets/img/weapons/browing_m1919.PNG new file mode 100644 index 0000000000000000000000000000000000000000..b35bbe229b4218f02fa6b83f90b5c1db93f5ec63 GIT binary patch literal 22519 zcmV)DK*7I>P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>DSByzSK~#8NRh@UZ zo<+6xz0T2dlpaDz@4ffldoPee4V^&fO{6F&AXpIuIbZ=45k&+QP!trT2MmOeP(mQ2 zCp$Ym6!Bc&`tIMop4mI+kGY;Uvu2fh)><>qywCf&og3ec9h+9Eyc@f>ycgRyydB#& zz7w_U--?}ExS!9m=zR04*tY&1)uX7cj$HG+mU}wx+WNkZs$0?Cx%oXERn*q!n^(uK z%^!&V`!2h-tkHcP-EOt-Q6E0Gi$=D8x9;4uxmJH1cZzP|kMr#tbu{fYv8!gS?yZh( z)Ky!~cdA~}P8oJ=(lgPnl^nZkK2Q-qTUOWgw|gt+&|d9lyS9pV=%6prpwHdn9T~Q7 z$TqvwCi!0F^WAE*{r~8oIv1b2x6z-DH7R$>A$nW$e(i?0V%xg6q%-laK54s4e8Pu$ zsq~f4q+8#s?juKCzsZwjlb%h(a*BVQbHDIw*|}Cb>#d@NjVN62q<`sScg=^s-YtH2 zRsC9r>Tid5+VsKl_#6t#&SWTzA;s60A|<9l0o2dk-E9IQg?ihhT$HeF-AZ34 zs4#+PN(kB%pmDuRj2KiKE5nPq9a1bxLb#okvbU{QzgD^wtCGNlZUQU&2iyV<$|!wu zO&yFSfpTMW}Q+OVA5BDPsZQ2 z)<<-Jez0ZoP!-zJj-%{Ap?~;GV5Q7O9|jY=Nbhyv7H#^#e%RDU`qw$U?~z>F#p}lR z--yq-v6Y{O*jaf9k79!+y&I!ympq)6MNz%2Ygd{vGlk)0sB-Nh!Kv?&sIuG4t1s0_ z!=;fDcqq8sQ`t;Cj(N|Z@~BQFVdNc~0a=bb3+nzDl_&?Lh)?sDJf{a!LFtmkN-KRA z{^1vIM0@oM-&V9ifO3mh!$9ambi}Lmg29l7Hqe2u%CnXB@Q8QYv+YCEfHokdj%7!0 zx{tT16@42W-ee0rpXG!$b*lE1V&HPUQxO9oK`-s~UiQO1XzBfCe%VD4cEd(qM-O0Vc9*c75Gk7+?f7v&ph z&=L*v1`zRNIs)cwFCO4U&kZ)UNf1#d`LZV}K-Uou0FMBL_IosM2c%2tf?|E6ugde4NC1u9DhYNK(MtLS z02$V}wtT8%8@R53QUNAuCa(sNy_Uj0_r({yaj%401B`n6$(ePk0j_gQZb{MzN zLjkE|wNbmIA-*cGVVKOjk{`TlG2#yAf9Vk%-b4fXR;v3>UhGxPkM4_J;SJizm;Eq; z0d|6+j(_)+hJ?2ahw8h4MasA5Ge6>8*O?O)1Ex{)99a|Klwzzjx}NEtL11|lb=-GF zr_iqo;oH{$O(i`GeTx?ro)rM6%=Df0@UnO?cr5hIKl-su$d|HkM85`_2VCI?K2+B{ zBySs6FTR7&x(Hrj?X zq?M^-T@q}-rnfTR;vXFV?gScjQsD&<=^0aQ1I@fv^>lt`K~h1dGFX-JN~r*rx)K(Vo_dQRHqMd8cdj-z#rZR%+{*VQxnawx56ng=}x zOqn_eTn1>$Xnv`$dc}s&gue}Vw3-i>EBC1f*|xQKm>khgpbZ4Vs6eOl^k!Tqkak+p zfF}*)P^IT|f=9E_#1Ns_^#mr5Y8Zl+3LzPqOe*S?2h#}3h*N)f7#AiUcoz(wN^pqO zeFP*Vkeig*Ylb)RqU+L60SAEO8TB$P09*_z42@FZHTktyWFV+NgV(Z3Ce@|bSgu!i zc{gg@U-IYhqUQ-z42SXMb73-a~q%Sg`jE%rt5$-~6bpji7oMs~FjRL)-N@ra}|Ys%yRh zp#UNe4{zzk3G2{J(%4hG zPWKZKnb>mr!s05YbPT>&xcLzip%uE4-O159Sw+LpY{4c@x{2s@HV*GLIVIKy?WUU z9<@vTB|nx;G~gW?EHB`dvM=4k7PR9Ud8_jbWzb03b}{JyU}|61yIfEG`niwwawX8z zpY6{5?B6y(=4ybJh{3u0vxg%+(4>jJyu6a$&y#^1m6g%}L1hGcG!xCvZ3rueP?tH9 zl`3YOdq_cPB!LeGE9Ln)`H~p3qAPH?xLz760gb_FmkP6zQ9Q0+=k#AZCT*ArZ3p1u z(IdK2a`we!AQb}b8l?o}T*zUCNrse@KIk(6mG#xPQwGgEt$5KxUIt$U2nL2nx9Y=V zF%ZvF{scSsJnB`7XRXr#ZB*ddpf_0HB})#3q`~5I=6KnU+W`_Cvp#jJXH{53M_#^= zhp|U<;eItB(4Fege}W~iQ(o+rgP_R9ePrTZ{dwB2JTLm7B14JEqtrFJ#q(Fr{xOCyS7<|Y}A0GENHwqB>hbJNE6^UX-| zC4-7*`pb|9=o4u4kDTyl<(jtyCt=ULxexS*94RCB_HJM6WfIG)c3wg-?+F%k=A5uP zau3htKIjX2>L&q*T_oz?HQ=>KQ<$ z2S}^-I!~LYe0Bny!;AW-J-Xk#2B4|JF2GieR*xzX0%Qo_jtv5U2y+#{WYFDP=`_Qv zJ(-nL>Tz9x!i?1e%%MaIWW?rm)&pp%H2PySQ3v|jpFu|Wzs1Lw=jm+>MzEGyxVYKv~+#}>M3CVgTOcWjq_>kYcLQ{R#8;$lP z_?12@J>)=1qq?|F8V((#fwN6|YVo4`T$d=4o;>@u4LGlg`_O%RGG4{0oq-_!T;Coo zL2R0UlcVbI(LkjXua&d-!fQ3luXF+5#nW*#zbe*~%7)cmN2^!9M=uN-D0rdtW_T39 z;;^dXX9%ET&{ZJaQx+s?uVX4kM;WSss4!D2fHEsfkC(-Yb^TPhXKbQD(qFTVx|#_~ zKuYnHv?gZ$zKupSBs@G*ImY$HGEc85@Dw?0}+ZrG3-WYp!tkXS4 zNP>;$r5#?tBS-c3?c5L_@7|>Fvo1c`rE?VkY>=tgGoU1dRq<5af}%M1hG)Rcps0AX z+%gV|)G>{m{5cHix(q1Jk&j~33RwKiBUN}p;P_R58wyz(AZs@Njq|(Grw{IoaQG% zStpC`6Fsa`^N+5w|E!a;!!!4{!-ME;${a0~32*>O0Td1FH83=u#S5JnmGv%#Cyciq z#bZc1d*5#g{94KeVWbn1bA)?T>w1gA)mG8^;jQ0}V~#m0ZoK}hvG%=X?u%U@Ta~ca ztYcA@qiBidR$agR!c+0wGk=O@uRRybRo;62g{Tp)o5jm!CHk8_T%md^oDXc)`IfaR zAFkB7t~p=(rutCd?qA^;(22L*GK@WzWMu8B&NEbsCIs4OC{Vz#KK3)HLmJOS1H+Y2 ztqi4>7FgITZ3=o+IIw!^`2pt z2~B!Cj^tW^Apuh7RktDm=ZTt9D0s{Im3>tw`^o{0cgukw z@JpsTpc#pPp#qteY=!9PaKRDfW*vi)b`~aGczOVyrN^jyDGoC>YJzW_fUsoA-(&jp zNzuARi_S&}HBW~+dXP0JS1u1%}x+NnddZQCXm96c-E zeDigcWwCtuYq9*zrSawpuKyV;R=gJPtzHrDt$Hn1u6Q|Kf9)T!G2 zczflmv7=JD7iNngCASlD-{Tsi;N2vn>XHMd{OAV*q+U2O(#vSiFq|GW^HFF5j`TnV zC(i(pb%s8nDS+7ylArscXM@rYbkQ-iFrbbf?b;mA{_WA2Gh=G>>d_@Gy^X0v(Bf!+$Y!c#bgG32sp@PyQ3TQ=ek_05V36Q zl7gwO=)&u!_m@{5jYbHnVz8M((3m+$0i%_PQaoZ(0M`*fFih^-0%*+6iZ{dyye*IL zPQ0`1xj66aQ)BMj*)e_Q%ZHzZpNiMr?XcZ(#rE+O=y88$Kk44jU9hh7O1U1N+7x)f+sxUyK+r zDCRDh8As2V8ly(3j-C%4J}A1&7_Hm1j){}T#)@Sx#y)w19h*=hiUA-xw}Pb)v>^SAr6QX&`meKgg#_`OP zkH^P5HpDi<0Sfj=uRtbQJQHWKQ0c}Vj-$`1qvw)62Voi}^C*mlg7Y4A{VuRF6d*G= zeFV(tkC`>zlEN+;!ZXGrGT;a*qYEUg8*;u)egFHD+W6%!eyTw>FgkVV8iNM(i={99 zUH#>(5U}xTt#11hyclSrp>rqjI=8)5GGyjC1t1c7@qT5NZ8_7HRQlSDQA8@CfDHla1gDMa`z3jWVDgwWP=YL9Z?TVTMeNYM*Is-gh7IW# zZQ8YswkoY#3uNtDMVmG)qGbiJBadtp4I3U2haGlUeBldU5HJTs-#!4XhZH#=CQlw0 z0|)ktuAMtY>t;=(XV(rfbNaX#KD=KXe9%F0zyV*10}ndT<%|FQrD$;Y;n7i`K3w!# z3)o$|b&f6yze}EfN{UT|mP9(e4{}LvK;#?+xl+EpQn;0jN0P@blL`aMUaQ_1nX_a& zfRQ>&y?L2L-itLna?qVlH(1Lng=ef8qFeDp8_~8$geKli_dnJI=`W8xEdBJA-j0k5 z&tK$R)w;)S(5E1aXn1YSyrg$ap2d^FhHa5w&yWLWg1k%?2)zoidv~l?-wc=>R0bM& zL0{^}uk4CW)L-Um0Bu?EXm~1B){#8PK$9}_MKi!bAxQ*gY!sFBZWxA9uTBlT?1)W@ zHEWm0HkGX(ydK+>l)w4%Q_-(yw`kkCwL%=A853H(b569pC z@`u>)!Af}yO=t*TOd8g%bh3_t5BIRZdQ_>A zt~fG*^3E6y!Yymwh?+Ih(YiOYXu?uMsWv${^}uj|Kzsl)mYd)OJAJxmbNv3{`(vzR zZY7=0KYB*2e(M#r6MX@=*1KHbF9(cglmH%E86@zG{nN=JDe>h?&Wp>x zd_i1!#U%#9w5gL~^ym@Mzh9r|*`u4nX3rQz9;WaxNFHtYkRdUD-t3rn^z@i6z)lc2 zM=R%}&Onu+!v@BXL49M`;J&KcKSn4|>)N?X%$qw?{m+Ui3c)1MojSFT&MH@4eqpS9 z{pRb>$7UIApGGvR>wC7NctQh%N=A_(yuc|B;8`Mi zsSJu2!V}aLc?dk{UawCw8KE!DQt#bI`4dckhM=Doq;ykX6lK2jK-cug2f4|-_ zpno6dY2CYbRU+I)VCg8(bX2aVu-v|llm6B%Tj_ac)$b+mI3Nbei_V!nC8i2w{rdNc zLk~VAnkZjups;$#!3W1l3+KlP#~mGgd-qUy?Wiz6C|L(PM?Nt}X|LA^KhrtXBQu$v*vPd#qo-CeBcnEx#{YiyaeVg4j`;V-wejzLH4drHZ5bTmmtvrKK~#bu5N;KhhiA&YXqlgFCGO+G zX$acw=~(w7kH|P|uU3H&pw)=!U0dFX`|r6udUWfk9OCe3-t-80oLrtGhs^ zi9nC;(W_6-=+U#gW}%%mA!x6huZ`-r64+WtI{~gk#}3h^W$TzcWmHU=G&E*Rn-B{Y z%!(5h&M^?l`}%2;arzl2#aFJnSfO=<`0Wx0A983MCa`xG?Ut=tM<*F^#<{pMbpMd#1Tgv z5(gjn#W?i91EW#H!{W$BhehK?4dTcn8W`+NnjGo2{rhwmn9qoBUi+0;@!E6o>BrmR zlYQIbqg|WiO*h02uYIkLwI8mGu@gplz@2jPaWP`};5bYA{PB;z9ryg~mN@Z*xpDg~ zH+p@MkW1*~ee2#G>--ylJ@Qn0Do^{-?hWzr-i@(u=SBf_vp}Uj_teB^0?%ik?2a9^ z8{@j`zZTPHPKyE3+1PQTW75R2%8w^0Rhb-*(d1AB@P`W4y z?(@8>YlhlQArY^JH)IxzR|35AkDeOGESMdW$n}&1_ENaP z3tf1@qBu_;s(puc(Wp_Qm@;)jOqx8#p0bw!Jz9C;h>=4D@~+WVfS)#PVssbiJIlke z^wO$z8_jM<#_{roV@3^){(XDJgh^w3f27(C8H`-rVye7Aw=SLK(Uk^B2YqzxuhLz- z4;aulX3m)!edVdgPZ+B_v}d#u|I?MX&OK^Mj8$4NRCET&6Zh@YO#^0p95a7TG}B=0 ztdwB-^r@On9~p-Ur~~`-kPh3&K?fcX-KEP5&p#t>`1%#`z3+S@e(?S4gG%11M&8LGQz$I2gPecK z(=qkt=U#ZR0z{t+pm;EnLca?GXlk?9GcI|_otxv*OU{x3+uBII`gD!1-8*?btB*!) zFL|dvQVL2%`IJ5aT2Fb6o~qMLz+#37&;}0ZEpUvCDN`nBqA=0`>mkqBU8S??3>`8c zjyrB{%+f?)!o(3WSg&X(;Rnm0({*q7u))zm;5+`fc}|`O50-Zl&`(-;Oq_SlX}a$3 znQkuuqPIf((FE+fNAu<_V$8T0;dEmV%n6k@*<<-2!X3_|GqI-Ub=~9?akEJ z*s((`>mhhY_19Sgkwxy#T{;Ss(rM=oPFFf;V0F}BAy0MD{myko*B#q>#@@DV3r)nv zh^J0UP1@L_&zdncF1qOKm^fifESNV(-g91j{`3DC-@EBrM>)SCAl$301!ztJDFz&2 z6R)M%E*H9X_3~J=>UFu^cND)@`e#$TPGF^X^9Rdglcud(b${D>#eKO-7I2vfeYpA! z%>o~aAN}Z-Sfu;~#=CS8^Ih7hw3m@ODclKoz2upCcN5Th2xJ1BJV9T1us$+&ubu!6 zg|v-!ZCfjhHg*0rM0wWqX_GWkySntyL;){1WKcgTe|+>+s2i#fHge>kXxOk}^b(jF z9dURZbkITZ#V>v_X3d@vLx&EIQKLtSW;5l4v*Wm9j{6QXHpzjnFz? zA!6=PGv%p<#p3f$RmhzeLk13%#~_4`iNTtjOqJK^)V`g)86LV*yLP%SFD;tGhsp~L z=%X;+)}aQEI-qZFO;QHL0D+UxGeQ%W*#a8*Y8!=pdp-fVeY@6i>~Tj~HyxD%AZu57 zL~>OoIwKV-8!5*eBrwg;U~15yK{RgCM6wOFuD*D{0WoI$m>4cun>Y<=B`?&>Ua+}} zk1bSMQd&fFMV@9HU3k_kTDESf^JbQ*y|-BBY3#ln;33QYavp~btr&d6c?qo-u)Bv33kcBUpZ9pr(! zIA3egw5esAzd&J4VW+u5PRnMEqJi|%sZ(p|=Ky=E&hi?pFAnJ6(-R`{$lmhytptLe zlJS5qeNo=x^Ilgh%YF}303P>%{17Q z3o0LM*zgdg2Zt-%HB@SGqyX1AOS8rrcujP!!g(`2YuYH9Hfg9yllW{(J?bABZCkdC zcCA~>!?%kr>L152ar{X2aY#Jy%b&?fvEd;nEjXV|{<)n(AK|XJIaZ2qRxEEW6xBg(TWIt& zmp~0;p$y(biudAd+g8!0t(cTs?}l%r%DHET+obM9&Cb8<|XJSi@_;)0kxXPO4kFwI!U z$t%r?(W8co&QJmGm{@q?0(-q_lP1Pk(Vd{=f09P|@h8rcx9$}qLd^eIE27~%Db2}OjDjVcJv74Ktp28=;1mJjbR$y zbOJ1Zr6{}V((QKGE7%b|pl~+?+MM&?kC9x+jGte|4V0+C~ zmtSBPe$v7P&LKw01GH(O862)tZWZ_1Os=(Q(<5|jWH;PaM!M|F7dZSKf8x=ar45Qn zntEUNwTm3fnSP(H5PZhzC&!s*oT74)iq18%&k&%Q6)swIYAjxSdMrNsj5zn4MRBe? zn9F%*#W{-?#o}|$l&4uF#o7@DghNCn~J^jq%HK7>q5Z$bC0wR`1_U+i)5V+Ty)-PP9&Qt$7xMSYtt%PNO6Q# z-aWRLXX~tqLRXFUzVZxA?@=73S1* zg~uxKUleD{8~0QgXL$tKzH;TI2KY413Og#{pCK=D!FgvZl+II_o#BPd*$TIvG&>zV zMjlsQ<%HwrYmgluP1IN4-n|@_$B!SY5Irr95+6s;nyx%nv(%XrW8Q)p4t*0QjED|x z1Tsy4$de8|>xW@~ii$-X;Xl@MeTi-t#dr_{kmLS6FBy*F7diD~V=Q{p~N0 z#=kz=Y8T2Yl8<+ODA)U;T(1&8jf~SyKGtD{cb6EayTCADVDGs6vc)lF+W2UvP(-+8 zip^+b=GmQa$r_o$WIF+_3xFd23v@l@ZOHAqaZf-blmTAWwg92m;pC0l%iyLf@6wGm zHHGr70%R9mcTytHeP*S7`}I_^epGZ65GTk(F(Ygv(0Az2-Z>)cT_Z*g_QEhbOPV!n zrf_$7G}MgsNad+brGu85jWtoYWEtd$YW=8%fJ=Vat6G*J60p9;WTBk~7M{M1W^*ky zxLRp&v8KoXYoq%<%A0kUE_(M;o+Ev<)BQ;T+We!Y#Lz)~G<)ss*JsRzPmzuh^pXQq(-tF$LAym>P+wqFcOI}s+WB?+i z_$9H3+<)&K(Ny7ZygbqLDPuMB=@?H7Xt>?YAF!ms%U@hFpG;U4@2z}AdC#y4Ofsy1 z)K^n_41M~k$2vTaxZ{PGSTK_$9|J_pYRSC_HIpWdmA9Fu#C>)wP{Mum+!--@)>Lnj zWJzQGyqQw!yjY~L`_-#2i8GWyck9|!!0l|s8feTy)eoTb!Y)fDeR|u&TzJvhu|PB~ zyX4%s@`{V&_~YltiSk~@96Mk1r^Ky4_-@>H-%sOfS6v#5Sm0I2rT>{Tr^<+vokL;F z8Pg`)EAsrzQ%{Ts?*D1Ly5w*1((`|gXP)}K2IWKX=SLri#~%4bJo4M0#czLgS3LN@ zkK>`={4^f^&ClYIhwhF){O;a(MA6{kU;iv_zxA5}evfE!WP@0|Xkk46%ww@?<&t>) zm8WC*>-oNQ)!YA!jl78Gy|FybuQmBs`xV}h0N=Rf?~E_2iS zuf4lam>8!i_^8oJ(iMtF z%5#t?j~0-}XoiM|V+J^QP;ci<`d3-l(<0 z#sP7#Ld$^%9TW#2dZ==tL*tOc4skhH;qp-3Ya#DMzY7H59-`Mr^u~`JE>EMGusjNh z`9TLC7~j6>`lwY1U^_YQ0hujr_+WXgSB|mvz1L&?>eqE#8Xvy*TC8IoQ3H;U!tWRe zLHm?o=k5yLOY*kmEj=gPsGbUW(sqvP**3M(8xQa_>sP%Re-HrgyZbipzFqfLQW5Tlw$X%bt(jY`>N_ z;x!{c^DYi=4+0c;piJKP?vzIp=t$yuabLI5)w@S@F072|9G{9PI=YUX{$5ImHR?vGZod6uEk~1rZ7+jvwV67rlD(?y^VB zP@;Y8v2*2V=E_@;$j^ymj+qt@onxT%1i262d5n%_i;%aYt1Y_O z^5JqN!Yp#Xt^xXbY?0oG2y73ck1Yc77J-;a*vCpkKHIy|GrfGvvQ9<@QeS6AquT5=^P`_jTtjWgS)Nrx+a>59uqb4V!O8p7y>g}xY(u+ zFmjKXK%r|u>|D(g0(mtrNhDx+g#=*oRZ=d4;oa=%s@4AlE_AEB44{>Jsz=)`a!j6e zde;VSPUn=zfB&m!r5R>_8Gq*V3DH;i!(xRiyxr#aUX9JGmr7B1wG}SJ>KC5*bKG#< zwQ>Ki?v1COd^DE4`mDi1sAO%Zlalo=3awKo^Afn5LTMZ27%eo5ZK9)c7bXT8jZFCo zdrSn#w+O}Ult;B{)5h=eu2eGq#;Z?D8Sg2lT;(5|u(M&O-2HCWZ=|>#Qs(A$GKdDw z9k+hV3#zS}H_v>oQ%6rG<{mvgrYKYo=qC>>{>TCExaHeY5@|$ktzqz`l2JPQ%oy3Z z->i0%l(>aZQSI9zROslfJcJmHeE(QS#k-72pw|K_&HlL0J7g6;7e*aE*+3oyZEri0 zXW_T0yPWs1F_V7bM>2~J;J;9_Zf2`Z8#nUSs*5jPteml_CK1hJmO}l9t6uX&V7ojk z`~Xx22|zNK3_5_t#6ZAcV!(`1bsY98xdELA7$Fz%DeSV#$M46*kMlkOF@M~X?Q#!a zg3h0udqto9flXCWI z=0d#xc=y(L`MD>(E$D*t&a&s=2PTUbFN|r^#znureKc!o9#f}`_iLGfnr(jVnycdL z*Ipe9Pd>r#;M%n%1PB-kr{hMCjO(wtJU)1PiSmSZBOc`SY9$#{3gi^_){b*}VR)m!)OQp->AW<$}T*eC`V z0OFqd22i>O(ERyQ-7EDo+yGQ&YYL$hz$E|^0lYIteQ?~iPUiqj`Y(f?Z>QlcGpPn- zX|P;a2W%`eeBcQ{YlZ(-Et_hBaINyopJ+zk%ma3SX4tR3@K@0wQt|CL-d!_Lq${;` zKBa;uvxidgPxA<)o^fWM2AO!Fj&)H13Vm?TArhILf~gM;K=X>usy6wN7bz%nG!()< zvUlRoe|$jG>!z9ob@VHgR;`-E@yc&DtyL&^f2qoA8l7(_47?$5@-sK~F|G7QxNYjF zc9Zf6Mmq-Bwmu$v^kL-?nu#j;pFCk?j2|;3#*H6o55=y4(@#0x>u*b5{ztsKa#{TI z)#qc}m{H2*hQ)(QmS24C@mQsVm_Jwe=dEgKL#B#vi2of+mE_G54?4vsLAzrb;CZ!9XOQOmzDzKr9zd!N2=-95gfYHGrkEu8>$;r_+z4y=9 zB#*XPc}IJ9e-D6mTh+%<0N6A+CXfp~2E}tvKkipP zojY{!JKnafTE%@ozf%*L52B}1j?Mxa3%r+KuviX(WsQ0-=-Huc!$K{Kta~)mEFpil z?(Z|$GE~D)VuFE4TNTs5yTf1dR(=QTfoYG-G50kxq3~C^%T0FJmeKg!(~m}{_AMn$ zC%*#f)S<1W+Ryq&CU~?hA1HhZXj|6-GI=(GP5B3mi4Fp95f9;hqeag5v{+MIp7&~To{PU$}oUD4MC%>ox zVDdJ(CoX?k%}TZNAbAdZo67heiA4c*_tSV;q$C-=cnJ(k7~$Hv7$8%zC&Zhm4&l!r z7A^Mb0iwEH$Ml1uZPaoOpLntas&~f7(0C5O#;zCcLkECD!@S@*82sX+d>R3|Wm$pc zq+=HxgMt*tQ6@bwQJz;`l^wFzedB8XYK+~+ z6UU8=*Z=uUeD>)MZ**X>ntgg~PIzPKb50X1AEB}o=V9=G9tedgV-6BLc7n(}!K>;! z-KcctB@y(1uHav?Bcp-Fb=4N#GPC|%Q2>VICKaE5`Y}%cdiUsRpml2B%5U~}Dw*Y1 zP1_VQw<$;4$~0Y5S0)97&yA~>%N?(ePxfqy|NYm_xcBG(BiG*B+lEFA9}tr!kIp?? zgZn9gZ5B`b>7iKl)+_Ps-~OnX?l0p{k3A4i{PEYZ^7R+QBn!wnB4C&!3OA27rUa6b z5FF$BhY%{oaH+HeX32$e#1*gtwj7~6BbPvdriY@Sq5ItPl7qdd!iaQ*g7JW*ze0}? zxxa@?pg>2wsGaGFhXl2XXt;mqVQ8MYT^1W%*SU5j4_eJ$0m=pEFZSC8-b%2zyt%sn z+XsIU-F1&2ShKh7$v-{f-4u8vG$P<|GNbm%7@Qgm$BDqfLCV?j6H z3@BW_|3++l@3q+Q-qKk2&a1Ij1p__&+h4?&FFrd?K4ET*9X&Kgj~*DqhYpM}qld)g zi6dgPLM6LvPdsL}ClFqT(`=Ss|9rG#O?;#yv&H-l8=euPrBQut<6#s8O*fu^K~UP3 zWq=$!MFB37(`!47Jd9Z>j5hF*p)D1Tf%&lw&j1$n{k5bzpbJ=4Xz*gDz~FG}(1{1i zi4}Dz3HAgW9=J>_Y*6S^M91H|5TE3vwvA{q3Gxqq1SUM&`VZfZ8PlhD#>ry#b=Q1F zU}B;9?O5{CU;S>jn>CNa%$bXbu?hif)mYd(t7c;nNNI1LC4|8$=Dnz%x(&!QqnqP2Ib7ly_?*7yN>N zprl;N=hrf8R%zy`WRKi}B$@AI-1C!LmCv<_ri~9*qOZ}`gAIv2HG9mr0prGwh%sZ> zL*Fm{`lkogCyRNxu*v%{hhU+f^(MkZCgjOD0@$lq@cxkIRg zwo6+aSW@En)4P6XZ#8gGKkt)!SmAQ7fX1scCS3%#;l*>4WweYN3f<3doS@{iyqk zo}vUQQ0~9?_UP8RZA_awAr_uE-<$DTw{Gt3z>OLRGn^ND3tY`r;!UsS}5NWo?v|a^+0eX0XAJfHnv{hfOZ`v3TURk?>E`64Ict}kG zq>s&-gl(4hzx3jBqeT-w=UrU_AE7uLLw1SVmy+l3!g> z3Lw(Etuz1)e!K(%Ah-&3P#VTg1u=Rghw3>*0uJ&G1P5?>?!3ca4oZcl+^MwkdDLQD zjJ^jt6>W}q*Yc~c43U*FRL~#%pj@x_iN6eY2}%zR@s{99V_D}a=m6Tu3p5HC#jl@h z;7K2Rqho_|;x+FsjoGuNN7JTF{jTuB4Kv4f%)!(^ipQagUA8$f<@RvV} zPj+uKJ^JuO#B!z{;4O89O*GgUbOs%Iqka`~(rLq$&sSJlCDYdOoWTOPowUabaQVrtLB85 z9FaDP6$fqJN~B?uHb#@abWD(;y9^&Z&(JBF6)&a_Z+e~sg*wT98aqJ=SnvYqBm2p7 zp0ktW{Z((o)TxuBakD1TpuwSv4nOb$^wzb?;Y4fOCdsgUgFpGzvv;rHr*^mg;9H&$ z6yOj7bC60m4%ewS`m?^s9W8gMuj{JhK+g7MfO(QcUF5e8h%~H8roOj)xI#d)Ko~q- zIp=glsgdE?E`!1Nw5K=5X+pp{4s}6(33L2lc*Cltv2pd%_~{+r@#`x#{_#7<>C?vf z2O6XKR@spLaphMo@hd7D#*Af-;*FOA7fSVls79BKgu*Lh>_2-e0|R3jj3`BQ>qjMC z388b{i0h<-=gWQzpa>@=aGLZcJ4}pa@9+m?gc(Rc7eWaf5d$36MT$`(8 zLWevf2h@PJJu4@|7bRI;@Ww}od5MFDDqc&{HLLr~Sp21&EqDZ6As;fqu&l@fvbT)b zEo<2Fl5Yuk2m0`D?(t`ojyraCv~S-k4m|Jx{|KYOVTZ<9i%yT7wHwSlOs7Bz9{PqK zayBo*q7aO#qtB(J9DNkPuKR??x&gTtM(F3_O$IR-R0kz^0I3e1&<0jNxwS>lFk}T7 zgNJLBM_bX@Ccrac0SM+#bqxv|D|N=PfGUBPMsyydBk2LO3((-pV6)d|Z7%5M;UON@~=MT^W5G zi|6t^430s3j*$RXdLf58DZxOhj7=R}Jl@WpQvL14bL&|N8&;^5lfdEC4Y?ajB{}j_ z>4Z=QD2hh$=0o%nD1eOf4BPN(53B23#Ha*;A1wj0s_Lf_} zE4;!AZ{tmVCEc_U!2ui{WIS~!S|4)CAOif8D^ub>c^4l^!e zi1aGO_2JR-r7O=d8t3^>B>t5;LRwh1z37_V9A(qr>Nor*f!(G2)&N2Ix(yo$GtWw; z(~k`;hCKz>u`YC|c*N^@xn@-QH4z39jXDp5p-_}~^q{GfX=Pb?6hz*n67z!oDB=fM0TKNoC!v$R>vX5{`YlA_SM9v5$j_43dhXj)mK@)wu4J$vR$9z2Q+;LPd@$jDW#0 zGIUen*>8G)93}L>pRp=_*wyQBkI@VqWI#wpTPE~{HX{$^k%uIJFl_ecSyTlodlQF) z%vpF|>iILA6+Mqy@r6#1Jv{)S7)7TSvsZ(z6J>x4y_Co2^b7Cs?LKnAKrcZ{J$ROf zmq+8*Nn4a>oq75~r6O&jMa!n~)L(udAMe>%@c~GazZ!v#&+EVV>|bJpCSNU@HIK_K zI!81_(}N)OZa||;^NROO;8;c<^W2fajz;p;@tzK%I4bPoK_>md3yvaZ)ADF+<@U@- z7tUSWD7^^;>^106&)M3{F^Quzfvegm2{3$aFfn2%{DUvy70~emRw)9oO0fpbmIRam zLcd~^6>I1_u^00&ZZE377#$gk(#Qr}#dn6PG!`JU3&%({7zPA{02@!pD9aIT1yQPV z_;J2s(4u_gEIDD)H&|33AR83AZ_i**SNK0PqhUjj;fil6k}$!mr|Ju4nd&#fDs+bI~lqJbV%4>`*Kf}c&fm0LGZ zf*t^XWU}%Ql~QPAH)uhqFTw&7?;`vQ>_9VAE*{W#@*thLPhou zB#_y@j-RSNbx?-G6Cut@sQ`yz^vpA3862T88h>J8ZzdWj(~7Iwcy!|%lZe!9c5I*-QFi!SeQJvq~)Y}d|B@vR%L3BE$! zrfs{p@7}xO)ffL3E0?{bK|nvqmpbeQ!nM@8qb%!ik|LaKkEn# zsIZx``prNJM#=D_N^Y1&MR|AxmKAtjR~IFr*s4=NRq_%8tP&s#Nxr;LZ8B;gRsC4$ zmGT{yEG#r~!8XCoGu3k)j6)mh0U89Qj%J4Y0S%C(H^qqbC;m`e)dxIy7lr!3IQCqU z$2_SN!+UTbqx!}p8iekk`n_+)yrX8s)F~7F zqwe8D2m5s(ucz+QM8Y%9Iy zukIC8eX%hqbSgsbP(qi#z(xpn2s3~O2 zCusmi2ZY%tN=@GBn?3(U**OT39|KPJN^WBvnE%w32N(Qk5aeu9U{@Qrhevo)eV<## z^y2V?ocG`Jli-8V{5pTgpaJpECC@1|)_TkQ?rq4NuYB-q{LXa6YyXJ;y}NjGB)e|; zychq)FkoOm@31*{(aG)$eiQtX6P+4-$qS%|M;3Cu8X*0kBlH2ta5Uw$o`J@-Wyt4V zsZhIhs{k6tvr=+YyFsPQ9H9af!ec0e$J1C5csx*EjbK2eFohSfAw@5ho!)`Atg#Ws zFt|stftv<#9RpEDQordy(rH8w<8=pK{{yv1z?@&X#cYi3wo-Sc{Ikx2iDE#LI)f{&AeOXraH3 z#AikiJLF)0tnf#-+!!lff8KxI<}?EP0kBH%MOMpO@#No0RQuP`i)A}6+8SJ0YZNh<#uPoHj+Fszycd%Lz*fyxp<{z1 zgWBsETVPyAeS0w#`s7{W%bra24KC62#y|Ii-1S}(dbDFOmL(26a{>!q#g~qyUg6oE zM05bz9_i$L%@miu@MIi6e~y1uKX%m6`1##;#jYKjG8a`b*aTqz7JzHc1-4K3Y>FK< ztK-!rPsKwI-Ww0xe|Icf`dsYOOqDNlS|0P4AS2m#o(n%J+%JRH{Gub>XBosGbQ;2d z0$6~meqz8qM-|CfeS0GfPeEY-4$p+rQJ4$HRwF;xc?m-{xJxQ7cUw~M0_s?80cC<# z3RGVPkNQaTrvQK#n$XJ8f$^(QqkGWFS4J2K!=@`Iq@>VU3g9#3r;>6ZI+cgP{fi@H z6m9y>T^fW8Xlv@fj$e1DH>7W7n)}l!=>+81b;`J@B!mVN0Sx^rGrU(#Dt z#u5n2mgPzepi}3y)`?1G zOaBVWBT>o#2>fv>J36f>m11N<48kXYTxu{V7@;C&CiIMg=klgF8v;NwJI!!~xA5Q5 zNTD{4`Y0X%AalQr9*ns^*pe6VWlw?t2#sP;Py)ac4k-r^0uU64GVR87j{?r?7j;#cnfK8@{NdGepeAlAo7py{qB5tg<=HQT z24w>>E3`^2Fs_Vd5X#7wQ}^jJbEmvdn8`J^>S*KBM>$6ZflhRtj|%j^y!Wn{q8TGU z3O?k(gZ;7RLk~H~-^;!Fic1vg>ibO(gcr{N<`ly6F%TIX)B)hq8Ephidsx{rBSrFI z&`3`1(Z2jR7+YFi>$d_Q`l*URsOL^mYzLN)cnAA#wN1#zmNGq}sgP(a2C=fGWdA^d zm36#QMHp~Fn05x4+h)5Q5ikVJs;I8xwvRcZ$^<>bYmS_1#8|Mxi;hx)1yOs4FDb7m zfOgbL&qJRa({rTPqi)hIa+yaflOt{L(9li=yS>hN@$T?yK2TzHw9ep>KJvGc@KmRC z3{Kys2Q7dPAC*y6(1n)))E{(xF1qO3X@>Ou&YLg#(^L2UXb1e1G(e={H`@JT0RVVdCJLINaecJ?~`cOYCP$LgATgT{K z&&mRO+Bx-yE}g6C-sUv|nt_D-)6qbqKo|m4^k{$Ee~xbtw)nRM*YaF zyKm;Q<1|n*m0lsQGbdS18S5(@L${zz?86fi|-4 z11>8NIdshS6%e^cThS?UB_GL?>q{Q%N7uzr)OUN7ZNpVY(Rq1cZ9wQqJLid_g>H%r zNt1MfD8nqC9Qs{(E*Ty#Sqo3%582hO=)|k}b+juP;Mumx!A7xNs)1^r(YXgp0+jaH zt9Z5lBRlk^k4#6@w(8qJvp(QQyx4BoNWAFCKS6)aL=jkwbB?RT2u|L0A7J_Kt2M&P zekBX0x;aX+&@o2lzB|(qfvrF@nO@8Wg*iXVdR~5DMruA%`NhLnSr%RbtA5D~?VZ$) zQo^h~f!otY{R_awcn;~}q0q~IbxmHTI(Sx$YM`iC$;fIClg0h2O#)8s7$ADiK=Eu# zV9xgSV8VzP2GAK?szaMP*=-E;BO^=qNjrJ9jFO4X3FyaLx~m`Vc=k?T$fGuXvr_3C z9?C#6I0Uf00(vg}5N<1vW<4Nhf(EdDq-QzED_KO-cz{mPn@ZX+`Od+|V9pN(W$0HO z$?mP?gie@s4(3EXJxPMEVpxQjDRLjCAZUZIT)FhxMC!YQ%>aw)6W%b6Usbse44wcf9+Urt&w(N1 ziU#+bhYD=wO`wH_L!#<9k5pTJVu+rK2g(tPUPRaZC3yVJF`mP___2(YccbuuJuiq> z?Ncw3k^0m_Up|KiJQ;e%F4a;@HLyx5W4l$dSr!0dx(Nd4rJkUz3BwM6#$*7;0Iz@t z=aA>VW^jpqeoP0jr~Y~7Pf*Bq@bC7bUBH%0H@X%-xw4S$SV(s0EKJ%J+c3#^0s`Sg zfTN0y4Pgvk)yaIR-je}RiWLomI>MB_Q`LtZfAY) znoIRKnuglzzJX;K;E(4zuLDSBSnDWl;iK+Zzp9sgBYV{bekz_Q=@2LYEcIVX>IE8U z^VF4i=b4|Yjq|hAWqL6Bsq|XvsUVlbEjEHb)$wG4XPoQ4pKD@PedGltP!UFP|JBO7 zuTv=M)cqrFMI?;`vxP8qp_9sXj${U5D2>PdHTP$lX()#`MrqaF_n}RQ(J0~CqgL1Z z3!VK`LhC#gmqMq}>J^YYCvU|Yaj$N{mvgnx+$i-xpBXOHwyN)EHhdb25MdsqP=K5~ z1JX2vw^<>(p8@)mC;2P%c$RNe_8)i!WZ1<#subCr|ERqIq`rN$O;W}>`KvkxlR=hr z(0@5{zvz)c<^fgJO*z!B_)5CmOJH)IDFJ#;P??X)Lzclr+4RAiWqBioLf~8xue=&U zssXN$FB5v5sUl!SOL?4UvYr65BCC#J7)E?FXmjgK0)RIKywuK+ZQ;#ywohhLx%6d^ zL_5Hw>ogi|QX$2N204Ao&8NaEg<5gRH_!Q0XyMTw$NlQabA{8CFJD!ew)iK^dVyBg z@KSOSE8cxmFTqBi<*%OkL@Cd0To0b)3A%ozra^4aS;<3Pg3f|Nc0hKzi5kgfmmj+y^E7Bc@P}5D&C782q>Rr$9$ht1~J&3A)dG32W7taP@ zHCfDArHXGgS|{!(^qxFvyB8Mp$G0VM?_5KsgRN`ZjTLSoEZ?PmJzFk2}OL2^xH zzi?n>!$g&{&|XKpq4%U&p~ax^kj#0;;x^SF5MR{I5e*$YUDi(zLBD`MdA8E&TfAim z&VmfIBdjD3$eVTGk@I>_onDv0;Tdyfc$Ap4%-rEoXlLPC^(7m|hX1^mUWztFXDM&h zhNI6@kMxaR$`X=oA$mp6rGBO;m0`JWSw)wDQfOju>QwU4M|v;;O4sPA0M>OXPH~=EtG<2#8b@Z7Wj!au zoD?J8;kWSU5r^TCQ|%Jyg^%Q&Vi~Ce57mI#pH8XkK>!ctsT!Q-m0#K?2x(UaF*@{y z1?i`RYxh+RGK>aE?Uk(pQGcRD1zb8o|OLkozi1%5akk zN4c4pr!gy`tM(`!n$A7V4+=p6=`GEx=s>RkI%(&KbShy5i=OJH!V|=$FZ5@CRUqTj z_;`HkrmnI*yqZUfp1B{LQwQ0Kq4BnEqml*{9geC~JY){)l!bfNLnU|dB#tDNZHN*M zeV!4gefI}Xf+NA08y0wNSO0noe(fBpK51J(mFH?N+!zOVOV`iAu<`w6@&5ttdH_CA Sb^rDN0000Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>DI-f~IK~#8NU7dM+ zpGS4>{nuT3KP9{4eUW!rmSx$JZP}J>dEf0gwi73g<0K9V36ub#a4D1m7YZ#cZ74f5 zDLW~YmZdb@k^-ePlt4lf$BE;8mF3-8fB@${-{;Io%Kc+L^S<-Wa^`u?Idf)y?<);B zd+gabee~HlqjdV{GjaCBb8$}TjIPfdQ#$^P%6I3p)xB?dVW^dXS>y>?$4d1o$fUTZO^#wKBfKc_nhicKl|RJGOlT} zUu{&TRPT}Dy!yL9pDK6zliAkw8JjV6PrYo%wfd=J(4U^=bLJ-5GzadfmojByF7AW& z>eqNkUO(^2{?C#$x1PgKTmGDDrZc)XUT#O9d*ih5ce(2z3-d00mTRydV8EPIS&iu2 z6V$Z{^L>^Bd``TAK}UN8c4?#fu2cF*;1E39kXJxVAf$SXOs;1lNcoH$LIA*k_8f={ zO1&VKvh26EE!XNR$4(Gy`B^2m={Z>PBqa&pd6CO_XdWlC4s9=}4T*B*ea6_N>?mR? zk_ae@O}P`-P8B~=!4JGthxt_Ol5{D@G4(4s23UKjAfiqkdD?^5252{ARSmEPGg+0n zV@)iHteV3B*(L|m!~oK5>Lil5N&@3KS05x3pf=R826J5|Wv#7*$a1K6;k1A|l?gyi z9C8A#6ZQKg?jh&Yp?Lnx(YSc}unDXEL!5Ig({sqNU9Pq$3`8-Xs+3j!)4ookz%%hF zb9E~7aDO>QC5dMyz9>g3tj0SoE>u3?0)tRN8gL3N@ZbnZS+y`adrqKhf@<8qGr&Ae z`(Eo{u+$3bp;_)r*VSsMSA`>|J-H{VF%y8)K9dDl)_4e{ZI%Jp`ICoAejqNE{QOA) z37AS3MaV^n?0uGibAREKtg{g8=eoXlO2p~DDjKf2Z%h=A_DZ@2K$GIRBP(*Mt?Kq1 zeYy~j^)XlQ%DNtxxfs_p0MFOBVlXT_>CrAqLFii+^{gr;C}tnS{G%ZO)8SL!aR%Q|oXE_$2GCwbQ|Gtm~@FA3T=S~#R0M;@hL%FB#(~Nh>*OY++ za_5+bOg3$L5n5ZSf}w=IL7%t_*JaXA2zYXAZXEGO;ET5_#$?NfI~!Y)wxB45-Tz0FvcH29oM<&stX%A<0T&bDjxo<|uKtWh@lH zW9dF!cgmLIXL}P^>lfv(2?aD!N*=os<>y64U0dm;=x3D*Q3-~V0a63NQL+Y_96CW8 zrA;CfVCfuUa^Xx;(S*}7UH7Gaa(yYpXfg&?xd}oTk?Us&_X;IQlI%*6h3B+aVsa(} zq6CBq6nGaTn=Rgx1+cXKs$eFWF$z&Rx0ElHfJEIYb7ErhHD&_Z*B79sq9rkvoUEf% zFfyJVBcEkiE(bYJ#!CEpF>{;JR^~NF?HfC<(_Lks9#>$|S0&fw^nEs6 z4gEil+bN);**SjZB%Ym--1J@htqXh|AGvyyi9*&oW+g6VHo&S^*DNr!vI!4xc6-(B z#nycl;=HY3-9nFx~-Ah^JcywY9dVq1mgntrl9%HcXn{jJesd5j7yWQaKROI=5Z1y~5K z?(zT638_sW{UWdM0Pr)quGW&{seck<*0Gf%bGkpsLGHv0nLHmz>(>YEj2m(lVy!eP zPeqYjs%N~ImvK|n=RiRPm^gs;9Ny*4E0xhQyBfeSv%6I%Bfd(a-B3vgRX4shK(MxW zFzN$lYIL2f!F1bflXV~@1@^s?*D)r@J?E`+d`p)cCwVx6!=lT|bqIdl(6)W>ZZ ze1k)x9t7^!^DILS11ZI}Yep_Sf-NZLV-l76Fh~-5<>k5O%6%>u;E0#9G8@}cugsVJ znd1N~g@@J%Rpy=`A}x&0$cgQDN6(>M_L(+wJx36`&C-VnQ9q|BqYqqy+ou(t$Hn0a zR5yub@<{{)ojV~yWK9>O>1XBo=>MX?z`Cr}r5*seJ$*7s1F8B-X-%ac?}>^fDJ9@o zP67cSCRXK;a)$ZnnF;Ha5fV_?yasUR;H7b%KZErr`BntRP&)0Su_UanGbT{;t>t$4 z9;;8OS@&F4WVAO>K7bI>R>I(HaK*A4A&`>yAPlU5Sierm{ZZF-)DF#n2x|o7A&kp& zQs=s}pWcZV*JweREWj982?PnJk4U%-Hb}NO%CQh~>LQbpC$34mo+m_JtfiD?;F3h~ z%wW3jG9Go^m*l7mK3E_xG8$A7)mtXnDbxHtMhT3nq99jwqrI*S#VyK<{9pa{kJt=(I~~QCro|IUz&hZEv7d zy@2Oc>U)hc;08Vk@I#p+76y@dq*2Mh3X+U0OF_8Kja-%y@?&kBOhD#~=MKk97mjNp zd*fuWOuGa6)OZPQF7SD(pafwi4Z)dE0>WgF;eyuGi#L#W#+ zhbZ^e_q-~Bl~b~!L`g;;N!2<|b6Q|8lnPcYp8m;yb@7DKNsj?Z1YjsBb4>-ve(VWM zvJ?dwnG?OrT*v)aar*zaZ`5nhkH=N6}YWl_ms9 znpF3}ryTZw;eo%2zxco($DXHu8r>r5$iBzo`2Hv2_<<*V-=lGQ)K||@^?95{oVAW? zj-fa^lPvB#C*;kb#vn2AUe^SudX7S%{XLpnx5n>QG<#CXmaVcLa;1~9a*lg=hCC+@ zJfZ8SqWjR3apIt4)4F`ZNoBr&O65G?Wtn=8AhU=SUda2;-~38^^~;}*U61|9^^WfU zMV!+3coi=bnu|%0@|a{{iM-&8WqI9Xu1etOxoTeiJqmzLQvZhlqU3!5xdBRconvuW zg8<5oi-i=j(H;nT{_LT6_`ClQb7!{1ut8VGYwx%@p566G?A`revG18j0O4x&^=qxyfVdgYpFXX>029XOGHFVl`|C>IJ8@Jo>ZMDC5OsY4|y_CPtQe6Ejw}`(@~{k<@&ijPsH2a z`o)+MpmPQd%OU;QAyl5vK5TnO!*;OCg0HmRorl zz(pQaU{R@^HbV$e89CNfJL@G)nvtYDD-A>=<<3M9uH=+J#w~pRyWfaUe(Xc>j<>%t zX0}g@sZ%G%g3dXy?Z(YT~eLOF)>6U;wTw!$-g+m58nq(l4d2?@yR4 zM{Qp?doW(q`g2NF)+1}^Ir^Ne=F!-&c2$fTJ|vo(CdT;jW20g8D5s%Ahs4!`u2w!c zuD*I;4A%9KA%kMXh+#2ei9G!D##+0TBYI9`_ z8#XY847w@?54<7<4ZJ*tTzy3h9dw1u#*S@>nX_i7&k-?r=dOm1yCY}!*RO+ei5cM-mvJFwc?o%o24UEe! zzf|)b5ucaa#{lp$1RRxw-n9`ca~T*HqDXF&(YAsKqLefr8rN;z6xT`P7R>F8u7#bk ze0f*QnKLWq&6^!_I%Y-JqWQ6?Ykn+Vv@lkzSQ2a3t&DXddEMGIv3Bj+ShsFXtXjDu zy1Ev`+u!llc+Y#^X)C-_R`_aZ?9QD#WFgz*=IvW!$IV;g#v87SZ8zNz+i$)xrnWT4 zq{)rZJheF{PiczACeq|+n$l#Nwz2m@dFbk%TfSP2}yT|`@@pdDV7FNN*tZA<7a)DJ7;EGdDT_1 zdDAuV=p#RjPki#jp3s~*9kF<+NRglm7R-%B3+KlT+qT5KxwB*0vaXoFV2%jt6gi7y z#mXhIZ22M)yDXM3TP%WB>Uz0IT^cKtR<2O_@+Bf*Yph?pI_8P!1)Ux0YgTkBpDWVm zLcU~~ElZlCF}8}ZYpz)v8#bT@%+{yP>4@u~GRo*Q|^6>sL!5l%yPM z)c+bO3i(=x?^$pNi+8dCBsu4WGtW*PVNJ{5@EoM6j)_WkX#|T&6sAfgGp!tVSgns|3`jV9 zc<0kb2@_)RlCIdeX#!VQj zax6q`8z)7hEQd5%R@5j9b)QPq87mpbjvJ#sXh&aTbl(ue5?c&?IP2T&$BWXMoYVvGQXsF|~7N^{pn`;6%(2tqsO&2io~ zy;YXb9_{V2hS@XasZ5%W4{ zi=YMt3&RB5XafXT3$?~<+_*vH%!<`(mPLoOZLWfY4r%^Exxfy&t+w{om?g3%ilE^N za{&%8$Bq?Q0%o!-qj_qR*OMu%Inr(bY*7DL&qxIy!$%BPP%^^#XhmTr1Cq!ECg3KS z>eq^tBvC#_WRFlEBk8XYM>(WSkTs$xSlD=7j~y@4L?DU?siV{u-%+xX$qOo%Y_&qcUAuoa2?l7U6p(jdtxkD3OYhgCgDD@x1B$O8*2FIwON@t=xP z@`y#mD#v#cF`hj9q=a}be)6CH8Ve-Uv}sd4!I2Vp)X3547Brz1E0<}lS+2;fL+jE~ z1rNBdxpr4`=FFBmoacqQO%^t0jNGLr3$TCzNVqM4n%tBLz$Vy|r11v7x>-Pm2=Y;G z575qwb&vx#X&mm0=XPPrbv;IPAYhCXq(S*e2-7?!%Kc4|B~Ot>6P!#^P%>WDP8|{o zF%~OCc~pOTYm4?*ua8}i|ECmOK^O*6ume-xQE`+lObH+`9Jqiia>{vL_EG)Znky%Z zRLHf8hFFuShi$AG)`(%=&P_*;qY*U#Un)67=gA_K|-h2 zl2#EpzY~IH$4sqLGu!0)l();8nq(o9<@*N@mK(k5N`=f*kelwOsPxp@T~Mp>9X};o1riw%Y=f zhGT^#4U;tza^t?S%;|Drt5&aym8(~3om{M7Xlblkv&v=56znWny11l8dcH_=U1Y_a z-QFIz@7Nam_x!@A7bc*8f)snPx?b(|GE}jz#$(q95rp6r^02arIo4x$6Nx538ZJs3NarN32dZt`youW9#q1_tg^tpWH@>r@dmM&#HwbfYMFBa&F7AsxN-B= zrW|yqWhv5Dszljvz*zX&4XT`c02e+y6-hpml4ZasL}4Y6$HpwlNlPNJFcO~s=EXDd z>t9`nb7yBD0V@KSy?Yh{p{l>WEmYZV7j_umt+@$@@R)xZAVx_|F7201d zlIz3W&FPpKGqo%aU{y#M`w5_i33XWVl04Y9auu7GJ#6eho|5OutKv_o8N z{v5f<+7>RDC(V{7<01s^ij~?!EeDVVCXS8olBIdwqICebN7&CP!cg3v+!E^$>t3rO z#ZE@y)|fBiMyi~Xf{etpW{xKM%e83SI6`xQOHv;blv74D z$Qn0TZf~gE_X;T%QK#MJP`O*l!l}fdtL0ABH>WB?hYj(G3(toQCk=6s(<)-OZdxDD zKK-cQV$XOV(6rOiT``HMyuUfrkdCXQ+c*PY1y*3d{u#ahGA(s}f)~57*Qk0pB z8V3y;;su-&g2|1IS|=974L5Ew;DZD#XK(}rCP``hc=a`TvQI2prnSWM>CRjagCty&?Myxbe{m2$bOR)7A;*OcgDK6OvaXEV{E(err5S^ zo7YU%$hGU%ijWl!faY|}j`32+Ay+Ffx$5$`;_^%5vP&Q7PqNy?|qn5s2pu0s75A8-=UO%O5d3RGrF<2iL$ zDA%@F1ak^8+j}r=mSxd1W=+>dvsHVu`3gRkYLlukd-QN^s5Rz2ECef2A!i*J4XEAjVV`n&kzXFnOAf8Z1G zx%)pBpZ(0=#OFTqk@&&`A5-}!;=wO`D!%%q`{Sz*-XCB1?8oEbhrZ?$9KiNZ8@lGR zB>#q2@P!nPk1qI^-u%_u^^38P5Abn@&8=7yrxArDZ-i}KG-5DVhPb`)oUH2MhrS-) z{m$3o{?B|k?!4pHn65}~s;rEYh6y5yqZ;-&>~%Wj?l}0w)o`NF+&snBHbwgo4mcqc z(x$aG%VH)*n}A-dh-t*|5z(r$YZcvX+_*NDDH~Y*t$&(W zxw6Z|a|DH@PZ&2cZoGbNeC2Z=i~sz=H{&Ni__p$I$B!QVX8ilZ--sVR{7=et{cqn= zQu+7)Iez@Z?>PVW?|myC`o@>z=Z}0pUbt`~etDrgUOIm~UOIa;etGVQ(qUa6ieD=I zO4l!6I2tcsJg#&iUVfolf!2xm)p=f6&B!`k9eA@BRisx80ypT_3-r9!wHJdZ<+wt- zzT73_H?yHT^|}RlIe zS2`|ch`bN|`TOF~{>L@;)3WksV&9&};vfG0ftWjcMhv{_iWsYP>AipQwm9(Yqmug> zMXCEF?}2#X%%PG5^tq${`TUop=ewO=64)=f?3mMw=Z{J`GZ64bRm$t%?vCzu;@xmw z63UnG^ft*8=(h zxufyp8XSq9;k^iC<>;oH2v%Tej&?WRBj&preyPSU)*P1N9gcH7e6hi{B(O%=$angXw8rA#71&!!P zW&Ae9@sUZgCS^(iG;!j62A437WHhl8hxWv;UOXKSfA?F8{yP2h!6@zcIVs1Lar(>| z9RUUBb&y7Ck#)4nqS|M*>b}Y35V}tlDXeW%wYmI**WT$huR#&p2!+7++;zKOk~y~j zNuNgZZPc0L`~7;@a|(r5En6Dzd-os5Z(iz-?gLK<2msApJ|OsYt@`u5(#6w9Uh!g0 z-v@3Y=alO3Yb+XW_m8VWd6OJ)fRu}cP+XM>Ao%i;QCf|$R?ZfPUn$N>9-imYlR3m> z5SL{(B(={^L+L;T2=YAdAGo5T50jDRjG%OoaXqHnw}#oG^!zk0P--I2?)sU}^!UBz zy6di0q}642#?Kyv*HdIE+~f8*!SFSaV0){sn-uOY@sEnre7#7a{M`8FKRu`o=?eb> z+%RTTyz}k%Mvq($FPi|Ge=U`=oK=4O$ey@MYvxDp`(XU`mpxLrU6Q{~W9z#XB)8+| zaI8usKYvDR3Iw6m)siZqzRosor@7X}-u+erMU-W-(z8rJZk~&RrGqy-7FOgWG9Z`0 zqP{w&GRAh&SOa)inYNj!^VE>+EH}VuWW-+kxK_~b|bI)43Pj~8y%RD&t-4YsX> zYse@>DAkugA*$EinaH#*K=UiodhQ=nHScPn?%%|v7qZ29TB6`n#z@US>+Ta>UuRI)PRu5F_X*p`CW;Z&UeR8{^Q?b zR(rcQksMB9IgOJi`sZ(m;M!^s3ZgsD)>Az_yF!Y1WT-0Ns@uLGV>}ku2L3krB2{^mKddFCFry%xs8Eq z>XNt?){sUY>dT7Zl*&>Am;vXt7@Or{YBu}wp_(lSLX=woGhT${`zQ^P=YqJx=LFib z@!OZ5kI#SZ0S6Lo?DX5)ydH6^Lm=Q#T&@oH$2$d6TNRe;ewr+aoDK6e8`s48wJYPU z*W4CwdGo!oeA$wC@QV+`#S1<0#8Z#U;+Ds&UiJI&mV55BOegmL!tdT4JMeV;?l&6$ z{!hfhd7bft?>%IoSu>cM*A{`ttI_^e*K^HSg?=So=@i{v@r z>$%;X#-WbzBu{J#c@;PH1J)4BHA?R1wY|p91BP_7jEn3(>+5A5VbC}PC`1t`IH_Y^ zfFws?w9s|RI)+AcAKD!+zx-0X=?$-skt0TG1KJj|I%X=`t7e#mU23BWxYaEx~4f4i?ld=M6!^0Ay;9FjZ?k{V~iw;H8Vd8qOA8DUYjZv z?B_)Q?#+W`1VV?9Qy0)A5rWeF@e?zy;K^S+;*)9)Ip=kDM!Q07EDCGEk|2dO$x)w4 z!~NxRT;vjMa(VOT`s+8w-LHF%0+RJU5g$HeNQ`M18C{FI;x%{O9rxaQuhKp7F71=< zeZ$>;A8(C9`gVo*Z+_D~3TPfvBuWG)KQHA&D=*50@pTB-UYC?tLeiQtPeLhn9Bd#6awxY4@^h8b9&#ynS-G$HDF;)CO82U9H4ZP75}^6a6`~xrszGjO zQ*e{S&e;QNO)Be9^}9A*Ye~Lr$#Y)7Vbo_KDEZGHOuAh2)|2oN6!99 z*Q`z6Pl=#>{i;#c*XGlRaoQwLZJHb_mM@CC@7@`I^7gmJhd%he__GhZE8h5qyR=SS zA6qtWh%MJ$6I-`l8{4ACx7@$`o#+m7KA8{Sh%4$q87^A+=V7 zRU%4RF6#XfBq*zB1@3_dl2 z18}XDoz4|8wc%J5(f}$mSrNQ-6PyEE^d6!*s znP2Eww!F*7L7Y6WSHg0znyGSwSQSwqWh80{>$;3m(Ry#VRh4RA#uSb9Y z&?ZS%W-tUcM3lS|WY@0TCkT4x@3bHaB~{900!z@fFmR4VsNC*R_r3k-+O7_=3b`iL z_0m0B9(kmyRQ_B@@>Q=U*Y|AM6({iNJM%~ctE%smQ(pbAwGV;ZX1|GIpl|-;gAT9x zi2?UFckV1(4Pp2+twFrhI87Fnu1|X@xxQuv7!b$0#p`1H6wjNkZ@%|+ajNHtKre29 zg&knA49VcPdj0FDwhMR#mHC4LO$wL6DFZ|~a#Mtsrw0FGUJnIESQ zwSUjsWlC6%lEGIZa4;TfS1l}EnaWHQm?nli&#h0AN8hPjl3jBkn6S~hzou$`R0(2T;_W0-i&MHnUil*O>yj1D6;YR7`YQhl{L@FiFH6O` zB`6aDWRZlmq@4L9CX>ktRSo%YRpmd z8{kfuymI^@n#tnF#+L7<{PKk?i&VA5CQru_o?{WNKmL&q$r8rJRJpp@9W(rX9(yO& zCI=95bNqx+Pa_B{rb3=vB|m|8DI{OGXm0$k-}@hN+m0J!|1&>RfU-;SU^$GT3274f zXABY)y9`YtH8d58V8H}Av`yv9C{KAg?ROdA_5<2|8hk8PeJVkcl|)Ku&DVS(sDHa^ znYd+Su>};IAjN?S zt(Mjd+j&pDO|J6F%YQ%q>d)WfkIQ-vK3M`8WFfGDgd96H5``)x>Aq?&DP3J^y-6`y zgy%H^q&+~ZjX{Sf^=**Tni!+L=#wC-U+SnVw=4-fc_pU;-n*P|tulw;PCU#0Q2;!X zmlYI(dbX_OgFR}V{P@qnu|}y91F3QUb*d7_;C*X?*hg4RjL&Q?o;emj{I`c<>5_#$ zROQ!KK6h!FGRa}LPsn9WwxSZ6;|j5?>21?guSvvq$eQNI@L@w^!nlU`zyI=ePrT>g zQwE8#5!Q>WwHQE|Xli0zGr4w9U=mW(IaRj{!rds(bGih~WS8?LFiPC zA&B+I7KTtXGGMw#2m!$h8^8U{#rVMc-V==zC&f$!1cc;R3Wu-`6iiZ*)w@Uq^VFjnQ;H(1GD%8yLC7Zn9RV6>fDuTRC$BjfH%M5$ zfC3=@#Wdz+-~o&i?Y=piEZV3~|FthEVs&SPsw8?Q4T^pUXF__g#9iPNv=yd z;4_Cp7C7ebP`dUxDhkPSNG~4FL0lS5zAh%gZ7J&19TlIPjnlFmN}Bbo%a;jg?a8Bi z<9cm8`Psd_t<699K@1yM|C(#E@)RPisaYiT5e30~ro#Jo!-oxw_rCkB+9Mr_6Z@ag zcnbTw8Bb$NQ2?DV8&Ckm7C{*V7!W`!^YF6-%)x*_zIa-o(^hk!p7&0WqTDzbgCx%< z0_wN_ngoE1WkOUJnfgvC)DEj+Y_+vDQO`==hi7ePuO$yf)SWmN68YVaO)m9tf65te zKu#|Gq}L{Ql&q)eai1y8lf;M2d*YYRpNwyO?Mr?)0XMgxYi_Ju&D%+QagkPJqV&vr zD5ZP@NlEL`^tP6mKYyO1#kRKQ7_ZR$!OuSsFR5QXdS(yDx|lC~l!-8TTn->Z&?|sr z9ZM2Sph$oWYd5*uKp^u=LTx3~g%pmM2<^+g0}O+nN|AjqUXtealpzoODbM{=sjoU% zhNDr{Gp;DO>aYhq;~1d+Y?RL`=02pQkJ4AZ*Vj8(4nj(@l1*!h?lWW;`?_XhdExA_ z_`sjO)30suTGzs^c`;}1OrJ_%RYZCwOk_<{jwSi$47t8`zUfu4v02v02Wk_>H^jPC z3*)(6KeGn&WnOAIYbfhvpWrDxE+#=X=sahVwwA1$!9x<6W$DrkE^C?Uzv2+q#1{+l z81%2ajA<)n?NYz#Zn=k$9N!o*ZfW03p7Em}>Y!Azl;SN2)<~95Dc3IMkYl=yl8GYB zPBZ0!1}}qDtpl(;lS(sZ?K-bbraNmvPWQT8N?pW@jJ=Ne3emwTsbASoYYYahkpSv3Z~>;;$#t_;H|2pyp1CQ2Ri zcl*BjmCwo&#`%2+zwz4D>CLOp_Sn>llq8K~qda^iVO50fd?mPP%e8)2yt!#?{Kfm< z6~Fu4FQfa2G)meJI5nEGRSR4;yftzIWRrzfLzJ%Z4#;R1XHp3SG-;lzEkJwS!MarT zy!v`xp*;2Im*1~E9-hYF3k$|lIwyGypuk0e>nAg=F9J}MTvH$kMI#_9?I}a~BpU$; zc(U$0N>!ORw<6CT#A9HU*#;Q~AbhW8T2s>el1<1<&4I8CEcgQ$3e~mfz2hx!j79|! zynei9^>Tlv32DC&QGJhxf}*Tg6l0Zs?KZ)t zl5f`sR^$aQ*Jb?bM)ST}S1Nzm{@9vu@fZUnX^j4JHW!@rNto(~`?ObO>hPlXB51ar zNi%4eL_HHv{ro>->z4ICM1`oe>sI*myS;t7PbCN#SeJM|0&+62$W1UWUE(hJQiAun z=XA`7t((`!p#w5~0mt7H!lJ6%LdMiYS`g&)8ikdE z>ztqaC}}_roIwKG!$yyL2%#~Ptpq|AByo)M4_~}L+8h{U zd>u|5PSFY3{T7nkYY53<(aO@{ijb0 zStf>k6wm!kB%y46mhF-><-UigbS>_a#F)4%%{RfK4hi71oNLw@E41qs8HtGPdGZ|5PWF*9F=xggm?#2g*v-3XNmSjt_?^8n3F;j`xXbIr zd*A&>F@9`AOmCSQOGFZXK7f5u`z(>fi%z7l-yo-oYKC`e=AnwnIt9w5WZF_*OX;>1_=;Ax_BB{1*WYnEh*Ox|||9jtxi)WA8?d8b>+Fih7 z{9d3^+o}t*>}3uqYXJumJ;@}xTAVG%q%mjpQO5AK-67>w8S=RU1^1oF`<-G@RunH0 zNmg2aj>|Pc8N{ZtKpG*rB0dw6>k`ix&pC5s%zpEyKe;~4F_Rm5u$)|-P);s2gWsNx z9DFKn-nPXbL-R$&+BGX2;kC`2<_Hd=W_Psv{d2yP;*^{(4A}_R^S;%q`J`o$tf?Wc z-?TRNJ@a!%Y4r!fY5|O!r{A1T=W!C?88|Gc1Ok?kNf~rVN}{TD=mTUax5Mend4(+lo)+=89!qst< z1VK%cCpt9e6C1wF!1_jy9v**p|403^1lt3jY?IriBmoJi5>Kh9+wU8CJBv8V*~2N+V_B)%7iv2=xH&1>uODrZ2V1GuSwO^^2aYv9PIYL4 zy4ONPmfcyACp9`rsup7lvb)Sa++K+`Nl4c5z2{0w*M3oi8zhvojNiYG-CONXW8!U0 zVy_$-jPd_S^1YM3<;1@)VQCNo?mq8J3(7H&pFXiLJ%h+HZXO}IQUZ`{bB$&a_SPtn zddW0(c%g+?vGzXwU$JA`M*n%XIoe0@SHl)8$iHbs#J8Fc&Q>mqby~+ZtXmZ~Zrv0+ zwr`D1*RGSx+z<;pXU7NM|4xPF64)dGj1pjC87%TDvn2qm!LIW3X*4pMEb0NG>Uk|v zp9Y~M*QamV)O*@7E}&3WTz%qFQw6hPAi#x(Uqt}?LKa{{3cq~gMw1kkEA@SJkyd5W z?DFJp)!vo~p}4^6HeH{6R1r8Q-H)iEow$H}hNh_#v#&we$SJ(!m)N6wACE)NJr)O* z4)a$ek31X4j_r-(#}v*V-=C@6pAZS%Ck{n-_u)7xz>e;J!joofuY~|s+!ifa4wVBr3`Cdf_~+&z{40&}WAA5a4>aFWrMfeV@T= z7Z#&y0tm}Pa3GIEc?K03#%Fs-LpBn1iL@a(2{GC9D}3EonJ47~ER=;sb9s=HNwUy{ zqS2bLNwY8#SZz|f5DG!r6Yw`$pYmI?Cl34~PV9d?x|L4s%hY{PWe4~>vQOw*^_2J| zrblfM)ctD$jE#`F_sFxwUebEyE8|PPT$kkLkdxd_&CAt*_M`lOv-j_reYdy-WE zK(mWdpNyNJr7#I@rj$qZkWF%>BBkcLt;tk>c6S61$ds}H0PX417FFt|{C!|1>A0j+ zH20_a2^2~}p2X4@dAiAzJLgcXiZ5Qux}v(N=vXDe3IFd<*!bO#-I`Mp!CLQ4vf9;v zqhf5Lk;T_UeHM)LN`Ug70Sl;}IL}f5gNQH?=X=#JK#=TbtBa5TjwIEeUn!=qlB`vL zpot_v31F2cfkS9%ASBfVp7xAMAB8A1IxQpRPfN-%>MyyFX%I*rUuDQe-G1}11z6dV z*eZX@gyQDBc~*N1aKN>ZV@>sqW49A74#x{YNjPJ3&-@cB+V`$eRRb(6 zzTU0`pI}i2lsl`YkAZYXh~3>EL1t%kMkV2T2lTw z0#biBre_A9mxA(77jdqbYZgkBo&zCRstuK~j78&pAv4tzzH=i~dsEX`g~5flN*sR_|2@U=28Jb)7&X zC-os3B_XWON%7bFxz0LCLM1ErVORrYDLc;-lpM#e8(FXd>jC}O_M z4-r^I&Nur`!g3DCT&;|{x|a1+veI>@wWd6hQT@_3|8k2SdZUSZP$63GFjwU^Sv1Q7 zWupWqlQplFxnluMJqyAbh5JCT1R$S56kuk;ZF#LweGV+CmonwVYQ1c$YsrSP)YAn= zet@S$9kkk;NZKMdsg6s(rSEK0W$gpBo+G+1%^z7I7#Zx^Xd@YTUO~?>)ZWCQtaaQZ z+Vhm`y<@4J+q$lBR>M$hrzb?JBme*tW-U-h7NFMQd2Tin zAW{-z%W=P1mINXYi3zix(lY~*L@^P|V^R|6+^3*08HJ%Rb)EEBJd@n^4#>d#{0s%i za6T0wU5l+n^>}>?`5i@y^jvRPry#B_Tup srw?!ge-xzPq5kZ0EqC6ZKe;ddABzVdnK0K-n*aa+07*qoM6N<$f_;!$#{d8T literal 0 HcmV?d00001 diff --git a/frontend/assets/img/weapons/flammenwefer41.PNG b/frontend/assets/img/weapons/flammenwefer41.PNG new file mode 100644 index 0000000000000000000000000000000000000000..316316dd4efe80caa1b997370ad8132f7ae724b8 GIT binary patch literal 25428 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 literal 0 HcmV?d00001 diff --git a/frontend/assets/img/weapons/luger_p08.PNG b/frontend/assets/img/weapons/luger_p08.PNG new file mode 100644 index 0000000000000000000000000000000000000000..58da18fb4f09e1ba0c54ab51dad2795239d36d7c GIT binary patch literal 17447 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 literal 0 HcmV?d00001 diff --git a/frontend/assets/img/weapons/mg34.PNG b/frontend/assets/img/weapons/mg34.PNG new file mode 100644 index 0000000000000000000000000000000000000000..410e55891dd46655a7aa486b87341132ac5047c5 GIT binary patch literal 17971 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

|!RYiW`* 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 literal 0 HcmV?d00001 diff --git a/frontend/assets/img/weapons/tokarev_tt33.PNG b/frontend/assets/img/weapons/tokarev_tt33.PNG new file mode 100644 index 0000000000000000000000000000000000000000..b693240b64b9dde774672ceb546d9dfaf7f0d6b5 GIT binary patch literal 15343 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 N002ovPDHLkV1mGb(G~yz literal 0 HcmV?d00001 diff --git a/frontend/assets/img/weapons/walther_p38.PNG b/frontend/assets/img/weapons/walther_p38.PNG new file mode 100644 index 0000000000000000000000000000000000000000..71ab2b2f66ff7c3b6fda7f212ea4393957f77fda GIT binary patch literal 15978 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 literal 0 HcmV?d00001 From 348997d27a872f41a31427193c4558b6cd1c474c Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 21 May 2026 20:31:33 +0200 Subject: [PATCH 26/39] fix: allow local frontend cors origins --- backend/.env.example | 2 +- backend/README.md | 4 ++++ backend/app/config.py | 2 ++ backend/app/main.py | 11 ++++++++++- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 42cd572..c252827 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,7 +2,7 @@ HLL_BACKEND_HOST=0.0.0.0 HLL_BACKEND_PORT=8000 HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3 HLL_BACKEND_DATABASE_URL=postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam -HLL_BACKEND_ALLOWED_ORIGINS=http://127.0.0.1:8080,http://localhost:8080 +HLL_BACKEND_ALLOWED_ORIGINS=http://127.0.0.1,http://127.0.0.1:8080,http://localhost,http://localhost:8080 HLL_BACKEND_REFRESH_INTERVAL_SECONDS=120 HLL_BACKEND_LIVE_DATA_SOURCE=rcon HLL_BACKEND_HISTORICAL_DATA_SOURCE=rcon diff --git a/backend/README.md b/backend/README.md index 5e5ff73..49d2505 100644 --- a/backend/README.md +++ b/backend/README.md @@ -176,8 +176,10 @@ HTML si una demo local necesita un intervalo distinto. Valor por defecto de `HLL_BACKEND_ALLOWED_ORIGINS`: - `null` +- `http://127.0.0.1` - `http://127.0.0.1:5500` - `http://127.0.0.1:8080` +- `http://localhost` - `http://localhost:5500` - `http://localhost:8080` @@ -1420,7 +1422,9 @@ locales mas comunes del proyecto: - `http://127.0.0.1:5500` - `http://localhost:5500` +- `http://127.0.0.1` - `http://127.0.0.1:8080` +- `http://localhost` - `http://localhost:8080` Las respuestas `GET` y `OPTIONS` incluyen `Access-Control-Allow-Origin` cuando diff --git a/backend/app/config.py b/backend/app/config.py index 9e668d7..bfc4c38 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,8 +41,10 @@ DEFAULT_WRITER_LOCK_TIMEOUT_SECONDS = 120.0 DEFAULT_WRITER_LOCK_POLL_INTERVAL_SECONDS = 1.0 DEFAULT_ALLOWED_ORIGINS = ( "null", + "http://127.0.0.1", "http://127.0.0.1:5500", "http://127.0.0.1:8080", + "http://localhost", "http://localhost:5500", "http://localhost:8080", ) diff --git a/backend/app/main.py b/backend/app/main.py index 249b3bc..262e592 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,7 @@ from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from .config import get_allowed_origins, get_bind_address +from .payloads import build_error_payload from .routes import resolve_get_payload @@ -24,7 +25,15 @@ class HealthHandler(BaseHTTPRequestHandler): self.end_headers() def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler interface - status, payload = resolve_get_payload(self.path) + try: + status, payload = resolve_get_payload(self.path) + except Exception: # noqa: BLE001 - preserve HTTP/CORS response on route failures + self._write_json( + HTTPStatus.INTERNAL_SERVER_ERROR, + build_error_payload("Unexpected backend error"), + ) + return + if status is None: self._write_json( HTTPStatus.NOT_FOUND, From de2cf726eaf9b45e5de522dd2b6d1e467faf74c6 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 21 May 2026 21:29:56 +0200 Subject: [PATCH 27/39] fix: handle postgres killfeed cursor parameter --- backend/app/rcon_admin_log_storage.py | 83 ++++++++++++------ backend/tests/test_rcon_admin_log_storage.py | 90 ++++++++++++++++++++ 2 files changed, 146 insertions(+), 27 deletions(-) diff --git a/backend/app/rcon_admin_log_storage.py b/backend/app/rcon_admin_log_storage.py index a2fa049..b32dca2 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -372,36 +372,65 @@ def list_current_match_kill_feed( else None ) if open_start_time is None: - rows = connection.execute( - """ - SELECT id, target_key, external_server_id, event_timestamp, server_time, - parsed_payload_json - FROM rcon_admin_log_events - WHERE (target_key = ? OR external_server_id = ?) - AND event_type = 'kill' - AND (? IS NULL OR id > ?) - ORDER BY server_time DESC, id DESC - LIMIT ? - """, - (server_key, server_key, since_row_id, since_row_id, limit), - ).fetchall() + if since_row_id is None: + rows = connection.execute( + """ + SELECT id, target_key, external_server_id, event_timestamp, server_time, + parsed_payload_json + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type = 'kill' + ORDER BY server_time DESC, id DESC + LIMIT ? + """, + (server_key, server_key, limit), + ).fetchall() + else: + rows = connection.execute( + """ + SELECT id, target_key, external_server_id, event_timestamp, server_time, + parsed_payload_json + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type = 'kill' + AND id > ? + ORDER BY server_time DESC, id DESC + LIMIT ? + """, + (server_key, server_key, since_row_id, limit), + ).fetchall() scope = "recent-admin-log-window" confidence = "partial" else: - rows = connection.execute( - """ - SELECT id, target_key, external_server_id, event_timestamp, server_time, - parsed_payload_json - FROM rcon_admin_log_events - WHERE (target_key = ? OR external_server_id = ?) - AND event_type = 'kill' - AND server_time >= ? - AND (? IS NULL OR id > ?) - ORDER BY server_time DESC, id DESC - LIMIT ? - """, - (server_key, server_key, open_start_time, since_row_id, since_row_id, limit), - ).fetchall() + if since_row_id is None: + rows = connection.execute( + """ + SELECT id, target_key, external_server_id, event_timestamp, server_time, + parsed_payload_json + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type = 'kill' + AND server_time >= ? + ORDER BY server_time DESC, id DESC + LIMIT ? + """, + (server_key, server_key, open_start_time, limit), + ).fetchall() + else: + rows = connection.execute( + """ + SELECT id, target_key, external_server_id, event_timestamp, server_time, + parsed_payload_json + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type = 'kill' + AND server_time >= ? + AND id > ? + ORDER BY server_time DESC, id DESC + LIMIT ? + """, + (server_key, server_key, open_start_time, since_row_id, limit), + ).fetchall() scope = "open-admin-log-match-window" confidence = "admin-log-boundary" diff --git a/backend/tests/test_rcon_admin_log_storage.py b/backend/tests/test_rcon_admin_log_storage.py index 406958f..7e62b4e 100644 --- a/backend/tests/test_rcon_admin_log_storage.py +++ b/backend/tests/test_rcon_admin_log_storage.py @@ -2,6 +2,7 @@ import gc import json import sqlite3 from datetime import datetime, timezone +from unittest.mock import patch from app.rcon_admin_log_storage import ( initialize_rcon_admin_log_storage, @@ -405,3 +406,92 @@ def test_current_match_kill_feed_filters_rows_before_incremental_cursor(tmp_path assert [item["killer_name"] for item in feed["items"]] == ["Next Killer"] gc.collect() + + +def test_current_match_kill_feed_without_cursor_omits_nullable_id_predicate(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-21T10:00:00Z", + "message": "[1:00 min (100)] MATCH START Mortain Warfare", + }, + { + "timestamp": "2026-05-21T10:01:00Z", + "message": ( + "[2:00 min (120)] KILL: Cursor Killer(Allies/steam-cursor) -> " + "Cursor Victim(Axis/steam-cursor-victim) with M1 GARAND" + ), + }, + ], + db_path=db_path, + ) + traced_sql = [] + connect = sqlite3.connect + + def connect_with_trace(*args, **kwargs): + connection = connect(*args, **kwargs) + connection.set_trace_callback(traced_sql.append) + return connection + + with patch( + "app.rcon_admin_log_storage.sqlite3.connect", + side_effect=connect_with_trace, + ): + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + ) + + kill_queries = [ + sql + for sql in traced_sql + if "FROM rcon_admin_log_events" in sql and "event_type = 'kill'" in sql + ] + assert [item["killer_name"] for item in feed["items"]] == ["Cursor Killer"] + assert kill_queries + assert all("IS NULL OR id >" not in sql for sql in kill_queries) + assert all("AND id >" not in sql for sql in kill_queries) + gc.collect() + + +def test_current_match_kill_feed_invalid_cursor_behaves_like_no_cursor(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-21T10:00:00Z", + "message": "[1:00 min (100)] MATCH START Mortain Warfare", + }, + { + "timestamp": "2026-05-21T10:01:00Z", + "message": ( + "[2:00 min (120)] KILL: First Killer(Allies/steam-first) -> " + "First Victim(Axis/steam-first-victim) with M1 GARAND" + ), + }, + { + "timestamp": "2026-05-21T10:02:00Z", + "message": ( + "[3:00 min (140)] KILL: Next Killer(Axis/steam-next) -> " + "Next Victim(Allies/steam-next-victim) with MP40" + ), + }, + ], + db_path=db_path, + ) + + without_cursor = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + ) + with_invalid_cursor = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + since_event_id="not-an-admin-log-event", + ) + + assert with_invalid_cursor == without_cursor + gc.collect() From 95b5dc4dfcba73cd139ef4a3c25fcfa0d07548e9 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Thu, 21 May 2026 21:51:57 +0200 Subject: [PATCH 28/39] fix: refine live killfeed screen and realtime stats --- frontend/assets/css/historico.css | 147 +++++++++++++++++++-------- frontend/assets/js/partida-actual.js | 90 +++++++++++----- 2 files changed, 167 insertions(+), 70 deletions(-) diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index 7a217b0..2d19c88 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -589,43 +589,84 @@ gap: 14px; } +.current-match-killfeed-screen { + position: relative; + width: min(100%, 760px); + min-height: 180px; + max-height: 520px; + padding: 10px; + overflow: hidden; + border: 1px solid rgba(183, 201, 125, 0.28); + border-radius: 6px; + background: + linear-gradient(180deg, rgba(183, 201, 125, 0.08), transparent 24%), + rgba(5, 8, 6, 0.92); + box-shadow: + inset 0 0 0 1px rgba(0, 0, 0, 0.44), + inset 0 18px 48px rgba(0, 0, 0, 0.24); +} + +.current-match-killfeed-screen::after { + position: absolute; + inset: 0; + z-index: 1; + border-radius: inherit; + background: repeating-linear-gradient( + 180deg, + rgba(255, 255, 255, 0.018) 0, + rgba(255, 255, 255, 0.018) 1px, + transparent 1px, + transparent 5px + ); + pointer-events: none; + content: ""; +} + .current-match-killfeed { + position: relative; + z-index: 2; display: grid; - gap: 10px; - max-height: 760px; - padding: 4px 0 0; + gap: 6px; + max-height: 500px; overflow: hidden; } .current-match-killfeed__row { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(102px, 150px) minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) minmax(92px, 124px) minmax(0, 1fr); align-items: center; - gap: 12px; - min-height: 56px; - padding: 12px 14px; + gap: 8px; + min-height: 48px; + padding: 6px 9px; overflow: hidden; - border: 1px solid rgba(159, 168, 141, 0.18); - border-left: 3px solid rgba(183, 201, 125, 0.34); - border-radius: 6px; + border: 1px solid rgba(159, 168, 141, 0.12); + border-left: 2px solid rgba(183, 201, 125, 0.46); + border-radius: 3px; color: var(--text); background: - linear-gradient(135deg, rgba(183, 201, 125, 0.08), transparent 44%), - rgba(14, 19, 12, 0.84); + linear-gradient(90deg, rgba(183, 201, 125, 0.07), transparent 40%), + rgba(15, 20, 13, 0.68); animation: current-match-killfeed-enter 180ms ease-out; } +.current-match-killfeed__row:first-child { + border-color: rgba(183, 201, 125, 0.28); + background: + linear-gradient(90deg, rgba(183, 201, 125, 0.14), transparent 46%), + rgba(20, 27, 17, 0.86); +} + .current-match-killfeed__row.is-teamkill { border-left-color: rgba(210, 182, 118, 0.64); background: - linear-gradient(135deg, rgba(210, 182, 118, 0.12), transparent 48%), - rgba(73, 49, 27, 0.42); + linear-gradient(90deg, rgba(210, 182, 118, 0.12), transparent 48%), + rgba(73, 49, 27, 0.32); } .current-match-killfeed__player { min-width: 0; overflow: hidden; - font-size: 0.94rem; + font-size: 0.88rem; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; @@ -641,35 +682,37 @@ .current-match-killfeed__weapon { display: grid; - grid-template-rows: 34px; + grid-template-rows: 25px auto; align-items: center; justify-content: center; - min-width: 102px; - min-height: 40px; - padding: 2px 8px; - border: 1px solid rgba(183, 201, 125, 0.24); - border-radius: 4px; + gap: 1px; + min-width: 92px; + min-height: 38px; + padding: 2px 5px; + border: 1px solid rgba(183, 201, 125, 0.18); + border-radius: 3px; color: var(--text); - background: rgba(7, 10, 7, 0.8); + background: rgba(7, 10, 7, 0.72); } .current-match-killfeed__weapon-icon { display: block; - width: min(100%, 116px); - height: 32px; + width: min(100%, 104px); + height: 24px; object-fit: contain; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.72)); } .current-match-killfeed__weapon-fallback { display: grid; - width: 32px; - height: 32px; + width: 24px; + height: 24px; + justify-self: center; place-items: center; border: 1px solid rgba(210, 182, 118, 0.3); - border-radius: 4px; + border-radius: 3px; color: var(--accent-warm); - font-size: 1rem; + font-size: 0.82rem; font-weight: 800; } @@ -678,20 +721,22 @@ } .current-match-killfeed__weapon em { - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; + display: block; + max-width: 112px; overflow: hidden; - clip: rect(0, 0, 0, 0); + color: var(--muted); + font-size: 0.58rem; + font-style: normal; + line-height: 1.1; + text-align: center; + text-overflow: ellipsis; white-space: nowrap; - border: 0; } .current-match-killfeed__teamkill { display: inline-flex; align-items: center; + margin-left: 4px; min-height: 20px; padding: 0 5px; border: 1px solid rgba(210, 182, 118, 0.36); @@ -932,27 +977,39 @@ } .current-match-killfeed { - max-height: none; + max-height: 500px; } .current-match-killfeed__row { - grid-template-columns: minmax(0, 1fr) minmax(96px, 128px) minmax(0, 1fr); - } - - .current-match-killfeed__teamkill { - grid-column: 1 / -1; - justify-self: end; + grid-template-columns: minmax(0, 1fr) minmax(86px, 116px) minmax(0, 1fr); } } @media (max-width: 480px) { .current-match-killfeed__row { - grid-template-columns: 1fr; - gap: 8px; + grid-template-columns: minmax(0, 1fr) 82px minmax(0, 1fr); + gap: 5px; + padding-inline: 6px; } .current-match-killfeed__weapon { - justify-self: start; + min-width: 82px; + padding-inline: 3px; + } + + .current-match-killfeed__player { + display: -webkit-box; + overflow: hidden; + font-size: 0.78rem; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + text-overflow: ellipsis; + white-space: normal; + } + + .current-match-killfeed__teamkill { + min-height: 16px; + font-size: 0.54rem; } } diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js index cd942f4..aa3ddb0 100644 --- a/frontend/assets/js/partida-actual.js +++ b/frontend/assets/js/partida-actual.js @@ -1,5 +1,6 @@ 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", @@ -13,10 +14,30 @@ const CURRENT_MATCH_WEAPONS = Object.freeze({ "m1 garand": { label: "M1 Garand", icon: "m1_garand.PNG" }, mp40: { label: "MP40", icon: "mp40.PNG" }, "m1a1 thompson": { label: "M1A1 Thompson", icon: "thompson.PNG" }, + "m1a1": { label: "M1A1 Thompson", icon: "thompson.PNG" }, thompson: { label: "M1A1 Thompson", icon: "thompson.PNG" }, "gewehr 43": { label: "Gewehr 43", icon: "gewehr.PNG" }, + "gewehr43": { label: "Gewehr 43", icon: "gewehr.PNG" }, g43: { label: "Gewehr 43", icon: "gewehr.PNG" }, mg42: { label: "MG42", icon: "mg42.PNG" }, + kar98k: { label: "Kar98k", icon: "kar98k.PNG" }, + kar98: { label: "Kar98k", icon: "kar98k.PNG" }, + stg44: { label: "StG 44", icon: "stg44.PNG" }, + "stg 44": { label: "StG 44", icon: "stg44.PNG" }, + bar: { label: "BAR", icon: "" }, + "m1919": { label: "M1919", icon: "browing_m1919.PNG" }, + "m1919 browning": { label: "M1919", icon: "browing_m1919.PNG" }, + "browning m1919": { label: "M1919", icon: "browing_m1919.PNG" }, + "m1 carbine": { label: "M1 Carbine", icon: "m1_carabine.PNG" }, + "m1 carabine": { label: "M1 Carbine", icon: "m1_carabine.PNG" }, + luger: { label: "Luger", icon: "luger_p08.PNG" }, + "luger p08": { label: "Luger", icon: "luger_p08.PNG" }, + colt: { label: "Colt", icon: "colt_1911.PNG" }, + "colt 1911": { label: "Colt", icon: "colt_1911.PNG" }, + "colt m1911": { label: "Colt", icon: "colt_1911.PNG" }, + "frag grenade": { label: "Frag Grenade", icon: "" }, + grenade: { label: "Frag Grenade", icon: "" }, + "smoke grenade": { label: "Smoke Grenade", icon: "" }, unknown: { label: "Arma desconocida", icon: "" }, }); @@ -48,19 +69,16 @@ document.addEventListener("DOMContentLoaded", () => { nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`; const killFeedState = initializeKillFeed(nodes); const playerStatsState = initializePlayerStats(nodes); - let matchRefreshInFlight = false; - const refreshMatchPanels = async () => { - if (matchRefreshInFlight) { + let currentMatchRefreshInFlight = false; + const refreshCurrentMatch = async () => { + if (currentMatchRefreshInFlight) { return; } - matchRefreshInFlight = true; + currentMatchRefreshInFlight = true; try { - await Promise.allSettled([ - loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }), - loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }), - ]); + await loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }); } finally { - matchRefreshInFlight = false; + currentMatchRefreshInFlight = false; } }; @@ -77,14 +95,31 @@ document.addEventListener("DOMContentLoaded", () => { } }; - void refreshMatchPanels(); + 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 refreshMatchPanels(); + 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 }) { @@ -120,7 +155,11 @@ async function loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsS ); renderPlayerStats(payload?.data || {}, nodes, playerStatsState); } catch (error) { - setState(nodes.playerStatsState, "No se pudieron actualizar las estadisticas en vivo.", true); + setState( + nodes.playerStatsState, + "Todavía no hay estadísticas fiables de jugadores para esta partida.", + true, + ); } } @@ -162,11 +201,9 @@ function initializeKillFeed(nodes) {

Cargando feed de combate...

-
+
+
+
`, ); } @@ -218,10 +255,10 @@ function renderKillFeed(data, nodes, state) { if (events.length === 0) { nodes.feedList.innerHTML = ""; state.visibleSignature = ""; - setState(nodes.feedState, formatKillFeedCoverage(data.scope)); + setState(nodes.feedState, "Todavía no se han detectado bajas en esta partida."); return; } - const visualEvents = [...events].reverse(); + const visualEvents = events; const visibleSignature = visualEvents.map((event) => event.event_id).join("|"); if (visibleSignature !== state.visibleSignature) { nodes.feedList.innerHTML = visualEvents.map(renderKillFeedRow).join(""); @@ -246,9 +283,13 @@ function renderKillFeedRow(event) { ? 'TK' : ""; return ` -
- +
+ ${escapeHtml(event.killer_name || "Jugador no disponible")} + ${teamkillBadge} ${escapeHtml(event.victim_name || "Objetivo no disponible")} - ${teamkillBadge}
`; } @@ -301,7 +341,7 @@ function renderPlayerStats(data, nodes, state) { nodes.playerStatsShell.hidden = true; setState( nodes.playerStatsState, - "Todavia no hay estadisticas fiables de jugadores para esta partida.", + "Todavía no hay estadísticas fiables de jugadores para esta partida.", ); return; } @@ -618,9 +658,9 @@ function formatKillFeedCoverage(scope) { return "Cobertura parcial desde AdminLog reciente."; } if (scope === "no-current-match-events") { - return "Sin bajas recientes asociadas a la partida actual."; + return "Todavía no se han detectado bajas en esta partida."; } - return "Todavia no se han detectado bajas en esta partida."; + return "Todavía no se han detectado bajas en esta partida."; } function normalizeLookupText(value) { From 22c22ce02ec7d93f8a23938f4c5aa114d3444f1a Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Fri, 22 May 2026 08:58:50 +0200 Subject: [PATCH 29/39] fix: refine two-column live combat feed --- frontend/assets/css/historico.css | 112 +++++++++++++++++++++------ frontend/assets/js/partida-actual.js | 76 ++++++++++++++---- 2 files changed, 150 insertions(+), 38 deletions(-) diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index 2d19c88..1a3d407 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -591,16 +591,16 @@ .current-match-killfeed-screen { position: relative; - width: min(100%, 760px); + width: 100%; min-height: 180px; max-height: 520px; padding: 10px; overflow: hidden; - border: 1px solid rgba(183, 201, 125, 0.28); + border: 1px solid rgba(159, 168, 141, 0.2); border-radius: 6px; background: - linear-gradient(180deg, rgba(183, 201, 125, 0.08), transparent 24%), - rgba(5, 8, 6, 0.92); + linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 24%), + rgba(7, 9, 9, 0.94); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.44), inset 0 18px 48px rgba(0, 0, 0, 0.24); @@ -626,34 +626,41 @@ position: relative; z-index: 2; display: grid; - gap: 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 8px; max-height: 500px; overflow: hidden; } +.current-match-killfeed__column { + display: grid; + align-content: start; + gap: 6px; + min-width: 0; +} + .current-match-killfeed__row { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(92px, 124px) minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) minmax(86px, 112px) minmax(0, 1fr); align-items: center; gap: 8px; - min-height: 48px; + min-height: 54px; padding: 6px 9px; overflow: hidden; - border: 1px solid rgba(159, 168, 141, 0.12); - border-left: 2px solid rgba(183, 201, 125, 0.46); + border: 1px solid rgba(159, 168, 141, 0.14); + border-left: 2px solid rgba(159, 168, 141, 0.24); border-radius: 3px; color: var(--text); - background: - linear-gradient(90deg, rgba(183, 201, 125, 0.07), transparent 40%), - rgba(15, 20, 13, 0.68); + background: rgba(19, 22, 22, 0.78); animation: current-match-killfeed-enter 180ms ease-out; } -.current-match-killfeed__row:first-child { - border-color: rgba(183, 201, 125, 0.28); +.current-match-killfeed__column:last-child .current-match-killfeed__row:last-child { + border-color: rgba(210, 182, 118, 0.22); background: - linear-gradient(90deg, rgba(183, 201, 125, 0.14), transparent 46%), - rgba(20, 27, 17, 0.86); + linear-gradient(90deg, rgba(210, 182, 118, 0.08), transparent 46%), + rgba(24, 25, 22, 0.92); } .current-match-killfeed__row.is-teamkill { @@ -664,22 +671,36 @@ } .current-match-killfeed__player { + display: grid; + align-content: center; + gap: 3px; min-width: 0; - overflow: hidden; font-size: 0.88rem; line-height: 1.2; +} + +.current-match-killfeed__player-name { + min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .current-match-killfeed__player--killer { - color: var(--accent-strong); + color: var(--text); } .current-match-killfeed__player--victim { color: var(--text-soft); } +.current-match-killfeed__player-meta { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + .current-match-killfeed__weapon { display: grid; grid-template-rows: 25px auto; @@ -689,10 +710,10 @@ min-width: 92px; min-height: 38px; padding: 2px 5px; - border: 1px solid rgba(183, 201, 125, 0.18); + border: 1px solid rgba(159, 168, 141, 0.18); border-radius: 3px; color: var(--text); - background: rgba(7, 10, 7, 0.72); + background: rgba(8, 10, 11, 0.74); } .current-match-killfeed__weapon-icon { @@ -733,19 +754,59 @@ white-space: nowrap; } +.current-match-killfeed__team-badge, .current-match-killfeed__teamkill { display: inline-flex; align-items: center; - margin-left: 4px; - min-height: 20px; + justify-content: center; + min-height: 18px; padding: 0 5px; - border: 1px solid rgba(210, 182, 118, 0.36); border-radius: 4px; - color: var(--accent-warm); font-size: 0.62rem; font-weight: 800; } +.current-match-killfeed__team-badge { + border: 1px solid rgba(159, 168, 141, 0.2); + color: var(--text-soft); + background: rgba(159, 168, 141, 0.08); +} + +.current-match-killfeed__team-badge--allies { + border-color: rgba(100, 139, 178, 0.34); + color: rgba(192, 215, 238, 0.96); + background: rgba(67, 101, 137, 0.28); +} + +.current-match-killfeed__team-badge--axis { + border-color: rgba(167, 109, 96, 0.36); + color: rgba(231, 198, 190, 0.96); + background: rgba(116, 68, 58, 0.3); +} + +.current-match-killfeed__team-badge--unknown { + border-color: rgba(159, 168, 141, 0.22); + color: var(--muted); + background: rgba(159, 168, 141, 0.1); +} + +.current-match-killfeed__teamkill { + border: 1px solid rgba(210, 182, 118, 0.36); + color: var(--accent-warm); +} + +.current-match-player-count { + width: fit-content; + margin: -6px 0 16px; + padding: 7px 10px; + border: 1px solid rgba(159, 168, 141, 0.16); + border-radius: 6px; + color: var(--text-soft); + background: rgba(14, 17, 18, 0.7); + font-size: 0.8rem; + font-weight: 700; +} + @keyframes current-match-killfeed-enter { from { opacity: 0; @@ -977,6 +1038,7 @@ } .current-match-killfeed { + grid-template-columns: 1fr; max-height: 500px; } @@ -997,7 +1059,7 @@ padding-inline: 3px; } - .current-match-killfeed__player { + .current-match-killfeed__player-name { display: -webkit-box; overflow: hidden; font-size: 0.78rem; diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js index aa3ddb0..ba08361 100644 --- a/frontend/assets/js/partida-actual.js +++ b/frontend/assets/js/partida-actual.js @@ -9,7 +9,7 @@ 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 = 15; +const CURRENT_MATCH_KILL_FEED_LIMIT = 18; const CURRENT_MATCH_WEAPONS = Object.freeze({ "m1 garand": { label: "M1 Garand", icon: "m1_garand.PNG" }, mp40: { label: "MP40", icon: "mp40.PNG" }, @@ -225,11 +225,15 @@ function initializePlayerStats(nodes) {

Cargando estadisticas en vivo...

+

+ Jugadores detectados: 0 +

`, ); } 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: "", @@ -249,9 +253,9 @@ function renderKillFeed(data, nodes, state) { }); const events = [...state.byId.values()] .sort(compareKillFeedEvents) - .slice(0, CURRENT_MATCH_KILL_FEED_LIMIT); + .slice(-CURRENT_MATCH_KILL_FEED_LIMIT); state.byId = new Map(events.map((event) => [event.event_id, event])); - state.latestEventId = events[0]?.event_id || state.latestEventId; + state.latestEventId = events[events.length - 1]?.event_id || state.latestEventId; if (events.length === 0) { nodes.feedList.innerHTML = ""; state.visibleSignature = ""; @@ -261,7 +265,7 @@ function renderKillFeed(data, nodes, state) { const visualEvents = events; const visibleSignature = visualEvents.map((event) => event.event_id).join("|"); if (visibleSignature !== state.visibleSignature) { - nodes.feedList.innerHTML = visualEvents.map(renderKillFeedRow).join(""); + nodes.feedList.innerHTML = renderKillFeedColumns(visualEvents); state.visibleSignature = visibleSignature; } nodes.feedState.textContent = formatKillFeedCoverage(data.scope); @@ -269,16 +273,34 @@ function renderKillFeed(data, nodes, state) { } function compareKillFeedEvents(left, right) { - const rightTime = Number(right.server_time); const leftTime = Number(left.server_time); - if (Number.isFinite(rightTime) && Number.isFinite(leftTime) && rightTime !== leftTime) { - return rightTime - leftTime; + const rightTime = Number(right.server_time); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; } - return String(right.event_timestamp || "").localeCompare(String(left.event_timestamp || "")); + return ( + String(left.event_timestamp || "").localeCompare(String(right.event_timestamp || "")) || + String(left.event_id || "").localeCompare(String(right.event_id || "")) + ); +} + +function renderKillFeedColumns(events) { + const splitIndex = Math.ceil(events.length / 2); + return [events.slice(0, splitIndex), events.slice(splitIndex)] + .map( + (columnEvents) => ` +
+ ${columnEvents.map(renderKillFeedRow).join("")} +
+ `, + ) + .join(""); } function renderKillFeedRow(event) { const weapon = resolveKillFeedWeapon(event.weapon); + const killerTeam = getKillFeedTeamDisplay(event.killer_team); + const victimTeam = getKillFeedTeamDisplay(event.victim_team); const teamkillBadge = event.is_teamkill ? 'TK' : ""; @@ -287,10 +309,15 @@ function renderKillFeedRow(event) { class="current-match-killfeed__row${event.is_teamkill ? " is-teamkill" : ""}" data-event-id="${escapeHtml(event.event_id || "")}" > - - ${escapeHtml(event.killer_name || "Jugador no disponible")} - ${teamkillBadge} - + + + ${escapeHtml(event.killer_name || "Jugador no disponible")} + + + ${renderKillFeedTeamBadge(killerTeam)} + ${teamkillBadge} + + ${escapeHtml(weapon.label)} - ${escapeHtml(event.victim_name || "Objetivo no disponible")} + + ${escapeHtml(event.victim_name || "Objetivo no disponible")} + + ${renderKillFeedTeamBadge(victimTeam)}
`; } +function getKillFeedTeamDisplay(value) { + const team = getPlayerTeamDisplay(value); + return team.key === "unknown" ? { key: "unknown", label: "N/D" } : team; +} + +function renderKillFeedTeamBadge(team) { + return ` + + ${escapeHtml(team.label)} + + `; +} + function resolveKillFeedWeapon(value) { const key = normalizeLookupText(value); return CURRENT_MATCH_WEAPONS[key] || { @@ -335,6 +378,7 @@ function renderKillFeedWeaponIcon(weapon) { 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 = ""; @@ -367,6 +411,12 @@ function renderPlayerStats(data, nodes, state) { setState(nodes.playerStatsState, "Estadisticas parciales derivadas de eventos recientes."); } +function renderDetectedPlayerCount(count, nodes) { + if (nodes.playerCount) { + nodes.playerCount.textContent = `Jugadores detectados: ${count}`; + } +} + function sortPlayerStats(items) { return [...items].sort( (left, right) => From 96a16cdee244f0540c154e597a1c76d477e9cb3c Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Fri, 22 May 2026 09:21:26 +0200 Subject: [PATCH 30/39] fix: restore home server action buttons --- frontend/assets/js/main.js | 64 +++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/frontend/assets/js/main.js b/frontend/assets/js/main.js index 16fb624..3400fdb 100644 --- a/frontend/assets/js/main.js +++ b/frontend/assets/js/main.js @@ -452,11 +452,65 @@ function renderQuickFacts(items) { } function getTrustedServerActions(server) { - const externalServerId = - typeof server?.external_server_id === "string" - ? server.external_server_id.trim() - : ""; - return TRUSTED_SERVER_ACTIONS[externalServerId] || null; + 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) { From 13cba1dcf1494074d2e570575340add59a8d5c38 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Fri, 22 May 2026 10:14:49 +0200 Subject: [PATCH 31/39] fix: resolve current match fallback snapshots --- backend/app/payloads.py | 63 ++++++++++-- backend/tests/test_current_match_payload.py | 105 ++++++++++++++++++++ 2 files changed, 160 insertions(+), 8 deletions(-) diff --git a/backend/app/payloads.py b/backend/app/payloads.py index f926d1b..69efea5 100644 --- a/backend/app/payloads.py +++ b/backend/app/payloads.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone +import re from .config import ( get_historical_data_source_kind, @@ -302,14 +303,7 @@ def build_current_match_payload(*, server_slug: str) -> dict[str, object]: # drops richer RCON session fields such as game mode and current scores. server_payload = build_servers_payload() server_data = server_payload["data"] - item = next( - ( - candidate - for candidate in server_data.get("items", []) - if candidate.get("external_server_id") == origin.slug - ), - None, - ) + item = _find_current_match_snapshot_item(server_data.get("items", []), origin) return { "status": "ok", "data": { @@ -350,6 +344,59 @@ def build_current_match_payload(*, server_slug: str) -> dict[str, object]: } +def _find_current_match_snapshot_item( + items: list[dict[str, object]], + origin: object, +) -> dict[str, object] | None: + """Resolve one trusted live snapshot for the current-match fallback.""" + origin_slug = str(getattr(origin, "slug", "") or "").strip() + source_markers = { + "comunidad-hispana-01": ("152.114.195.174", ":7779"), + "comunidad-hispana-02": ("152.114.195.150", ":7879"), + }.get(origin_slug) + server_number = getattr(origin, "server_number", None) + + for item in items: + if any( + str(item.get(field) or "").strip() == origin_slug + for field in ( + "external_server_id", + "server_slug", + "target_key", + "slug", + "community_slug", + ) + ): + return item + + server_label = str(item.get("server_name") or item.get("name") or "") + if _current_match_server_name_matches(server_label, server_number): + return item + + source_identity = " ".join( + str(item.get(field) or "") for field in ("external_server_id", "source_ref") + ) + if source_markers and any(marker in source_identity for marker in source_markers): + return item + + return None + + +def _current_match_server_name_matches(server_label: str, server_number: object) -> bool: + if not isinstance(server_number, int): + return False + + normalized_label = server_label.strip().casefold() + if not normalized_label: + return False + + numbered_marker = re.compile(rf"(? dict[str, object]: ): payload = build_current_match_payload(server_slug="comunidad-hispana-01") return payload["data"] + + +def _build_with_snapshot_fallback( + server_slug: str, + item: dict[str, object], +) -> dict[str, object]: + with ( + patch("app.payloads._query_current_match_rcon_sample", return_value=None), + patch( + "app.payloads.build_servers_payload", + return_value={ + "status": "ok", + "data": { + "last_snapshot_at": "2026-03-24T14:08:41.008487Z", + "items": [item], + }, + }, + ), + ): + payload = build_current_match_payload(server_slug=server_slug) + return payload["data"] From dfdb53d2b602f9cc468d0a77e8621a5622fe3460 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Fri, 22 May 2026 10:57:30 +0200 Subject: [PATCH 32/39] fix: improve historical worker visibility and recent pagination --- backend/app/rcon_historical_worker.py | 99 ++++++++++++++--- frontend/assets/css/historico.css | 85 ++++++++++++++- frontend/assets/js/historico.js | 146 +++++++++++++++++++++++++- frontend/assets/js/partida-actual.js | 3 - frontend/partida-actual.html | 11 +- 5 files changed, 323 insertions(+), 21 deletions(-) diff --git a/backend/app/rcon_historical_worker.py b/backend/app/rcon_historical_worker.py index 88206cb..00f2f10 100644 --- a/backend/app/rcon_historical_worker.py +++ b/backend/app/rcon_historical_worker.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +from datetime import date, datetime import json import os import time @@ -201,34 +202,52 @@ def run_periodic_rcon_historical_capture( ) -> None: """Run prospective RCON capture in a local loop.""" completed_runs = 0 - print( - json.dumps( - { - "event": "rcon-historical-capture-loop-started", - "interval_seconds": interval_seconds, - "max_retries": max_retries, - "retry_delay_seconds": retry_delay_seconds, - "target_scope": target_key or "all-configured-rcon-targets", - }, - indent=2, - ) + startup_targets = _describe_loop_targets(target_key) + _emit_worker_event( + "rcon-historical-capture-worker-started", + interval_seconds=interval_seconds, + max_retries=max_retries, + retry_delay_seconds=retry_delay_seconds, + target_scope=target_key or "all-configured-rcon-targets", + target_count=len(startup_targets), + targets=startup_targets, ) print("Press Ctrl+C to stop.") try: while max_runs is None or completed_runs < max_runs: completed_runs += 1 + _emit_worker_event( + "rcon-historical-capture-cycle-started", + run=completed_runs, + ) payload = _run_capture_with_retries( max_retries=max_retries, retry_delay_seconds=retry_delay_seconds, target_key=target_key, ) - print(json.dumps({"run": completed_runs, **payload}, indent=2), flush=True) + _emit_worker_event( + "rcon-historical-capture-cycle-finished", + run=completed_runs, + result=payload, + ) if max_runs is not None and completed_runs >= max_runs: break + _emit_worker_event( + "rcon-historical-capture-sleep-started", + run=completed_runs, + sleep_seconds=interval_seconds, + ) time.sleep(interval_seconds) except KeyboardInterrupt: print("\nRCON historical capture loop stopped by user.") + except Exception as exc: + _emit_worker_event( + "rcon-historical-capture-worker-exited-unexpectedly", + error_type=type(exc).__name__, + message=str(exc), + ) + raise def _run_capture_with_retries( @@ -248,12 +267,32 @@ def _run_capture_with_retries( } except Exception as exc: if attempt > max_retries: + _emit_worker_event( + "rcon-historical-capture-attempt-failed", + attempt=attempt, + max_retries=max_retries, + error_type=type(exc).__name__, + message=str(exc), + retries_exhausted=True, + ) return { "status": "error", "attempts_used": attempt, "error": str(exc), } + _emit_worker_event( + "rcon-historical-capture-attempt-failed", + attempt=attempt, + max_retries=max_retries, + error_type=type(exc).__name__, + message=str(exc), + ) if retry_delay_seconds > 0: + _emit_worker_event( + "rcon-historical-capture-retry-sleep-started", + attempt=attempt, + sleep_seconds=retry_delay_seconds, + ) time.sleep(retry_delay_seconds) @@ -275,6 +314,42 @@ def _select_targets(target_key: str | None) -> list[object]: return selected +def _describe_loop_targets(target_key: str | None) -> list[dict[str, str]]: + """Describe configured worker targets without exposing credentials.""" + try: + targets = _select_targets(target_key) + except Exception as exc: # noqa: BLE001 - startup logging must not hide capture error + return [ + { + "status": "unavailable", + "error_type": type(exc).__name__, + "message": str(exc), + } + ] + return [ + { + "target_key": build_rcon_target_key(target), + "external_server_id": str(target.external_server_id or ""), + "name": str(target.name or ""), + } + for target in targets + ] + + +def _emit_worker_event(event: str, **fields: object) -> None: + """Print one JSON worker event using safe date/time serialization.""" + print( + json.dumps({"event": event, **fields}, indent=2, default=_json_default), + flush=True, + ) + + +def _json_default(value: object) -> str: + if isinstance(value, (date, datetime)): + return value.isoformat() + return str(value) + + def get_rcon_admin_log_lookback_minutes() -> int: """Return the AdminLog lookback window used by periodic RCON capture.""" configured_value = os.getenv("HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES", "60") diff --git a/frontend/assets/css/historico.css b/frontend/assets/css/historico.css index 1a3d407..a3b08ba 100644 --- a/frontend/assets/css/historico.css +++ b/frontend/assets/css/historico.css @@ -589,6 +589,61 @@ gap: 14px; } +.historical-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-top: 16px; +} + +.historical-pagination[hidden] { + display: none; +} + +.historical-pagination__size, +.historical-pagination__nav { + display: flex; + align-items: center; + gap: 10px; +} + +.historical-pagination__size { + color: var(--text-soft); + font-size: 0.86rem; + font-weight: 700; +} + +.historical-pagination__size select { + min-height: 42px; + padding: 0 34px 0 12px; + border: 1px solid rgba(159, 168, 141, 0.2); + border-radius: 6px; + color: var(--text); + background: rgba(13, 17, 12, 0.72); + font: inherit; +} + +.historical-pagination__nav p { + margin: 0; + min-width: 110px; + color: var(--text-soft); + font-size: 0.86rem; + font-weight: 700; + text-align: center; +} + +.historical-pagination .historical-tab { + margin: 0; +} + +.historical-pagination .historical-tab:disabled { + transform: none; + border-color: rgba(159, 168, 141, 0.12); + color: rgba(169, 173, 154, 0.48); + cursor: default; +} + .current-match-killfeed-screen { position: relative; width: 100%; @@ -795,9 +850,22 @@ color: var(--accent-warm); } +.current-match-player-intro { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 10px 18px; + margin-top: 10px; +} + +.current-match-player-intro .historical-panel__note { + margin-top: 0; +} + .current-match-player-count { + flex: 0 0 auto; width: fit-content; - margin: -6px 0 16px; + margin: 0; padding: 7px 10px; border: 1px solid rgba(159, 168, 141, 0.16); border-radius: 6px; @@ -1004,6 +1072,11 @@ flex-direction: column; } + .current-match-player-intro { + align-items: flex-start; + flex-direction: column; + } + .historical-mvp-card__meta { grid-template-columns: 1fr; } @@ -1025,6 +1098,16 @@ justify-content: flex-start; } + .historical-pagination, + .historical-pagination__nav { + align-items: stretch; + flex-direction: column; + } + + .historical-pagination__size { + justify-content: space-between; + } + .historical-tab { width: 100%; } diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index c4312a5..0d90568 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -19,11 +19,15 @@ const DEFAULT_HISTORICAL_SERVER = "all-servers"; const SNAPSHOT_CACHE_TTL_MS = 120000; const STALE_SNAPSHOT_CACHE_TTL_MS = 30000; const NEGATIVE_SNAPSHOT_CACHE_TTL_MS = 15000; +const RECENT_MATCHES_LIMIT = 100; +const DEFAULT_RECENT_MATCHES_PAGE_SIZE = 10; +const RECENT_MATCHES_PAGE_SIZES = Object.freeze([10, 25, 50, 100]); let activeServerSlug = DEFAULT_HISTORICAL_SERVER; let activeLeaderboardMetric; let activeLeaderboardTimeframe; let activeServerRequestId = 0; let activeLeaderboardRequestId = 0; +let recentMatchesPagination; const LEADERBOARD_TIMEFRAMES = Object.freeze([ { key: "weekly", @@ -100,6 +104,7 @@ document.addEventListener("DOMContentLoaded", () => { const recentSnapshotMetaNode = document.getElementById( "recent-matches-snapshot-meta", ); + recentMatchesPagination = initializeRecentMatchesPagination(recentListNode); const params = new URLSearchParams(window.location.search); activeServerSlug = normalizeServerSlug(params.get("server")); @@ -126,7 +131,7 @@ document.addEventListener("DOMContentLoaded", () => { recentMatchesCache, pendingRequestCache, buildRecentMatchesSnapshotKey(serverSlug), - `${backendBaseUrl}/api/historical/snapshots/recent-matches?server=${encodeURIComponent(serverSlug)}&limit=10`, + `${backendBaseUrl}/api/historical/snapshots/recent-matches?server=${encodeURIComponent(serverSlug)}&limit=${RECENT_MATCHES_LIMIT}`, ); const getLeaderboardSnapshot = (serverSlug, timeframeKey, metricKey) => @@ -169,6 +174,7 @@ document.addEventListener("DOMContentLoaded", () => { weeklySnapshotMetaNode, `Preparando datos ${activeTimeframeConfig.shortLabel}...`, ); + resetRecentMatchesPagination(); recentListNode.innerHTML = ""; recentNoteNode.textContent = buildRecentMatchesNote(activeServerSlug); setState(recentStateNode, "Cargando partidas recientes..."); @@ -635,20 +641,156 @@ function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) { buildSnapshotMetaText(payload, "Partidas pendientes de generacion."), ); if (!payload?.found) { + resetRecentMatchesPagination(); + listNode.innerHTML = ""; setState(stateNode, emptyState.recentMessage); return; } const items = payload?.items; if (!Array.isArray(items) || items.length === 0) { + resetRecentMatchesPagination(); + listNode.innerHTML = ""; setState(stateNode, emptyState.recentMessage); return; } - listNode.innerHTML = items.map((item) => renderRecentMatchCard(item)).join(""); + setRecentMatchesPaginationItems(items.slice(0, RECENT_MATCHES_LIMIT), listNode); stateNode.hidden = true; } +function initializeRecentMatchesPagination(listNode) { + if (!listNode) { + return null; + } + + listNode.insertAdjacentHTML( + "afterend", + ` + + `, + ); + const pagination = { + items: [], + page: 1, + pageSize: DEFAULT_RECENT_MATCHES_PAGE_SIZE, + root: document.getElementById("recent-matches-pagination"), + pageSizeSelect: document.getElementById("recent-matches-page-size"), + previousButton: document.getElementById("recent-matches-page-prev"), + nextButton: document.getElementById("recent-matches-page-next"), + status: document.getElementById("recent-matches-page-status"), + }; + pagination.previousButton?.addEventListener("click", () => { + if (pagination.page <= 1) { + return; + } + pagination.page -= 1; + renderRecentMatchesPage(listNode); + }); + pagination.nextButton?.addEventListener("click", () => { + if (pagination.page >= getRecentMatchesPageCount(pagination)) { + return; + } + pagination.page += 1; + renderRecentMatchesPage(listNode); + }); + pagination.pageSizeSelect?.addEventListener("change", () => { + pagination.pageSize = normalizeRecentMatchesPageSize( + pagination.pageSizeSelect.value, + ); + pagination.page = 1; + renderRecentMatchesPage(listNode); + }); + return pagination; +} + +function resetRecentMatchesPagination() { + if (!recentMatchesPagination) { + return; + } + + recentMatchesPagination.items = []; + recentMatchesPagination.page = 1; + recentMatchesPagination.pageSize = DEFAULT_RECENT_MATCHES_PAGE_SIZE; + if (recentMatchesPagination.pageSizeSelect) { + recentMatchesPagination.pageSizeSelect.value = String( + DEFAULT_RECENT_MATCHES_PAGE_SIZE, + ); + } + if (recentMatchesPagination.root) { + recentMatchesPagination.root.hidden = true; + } +} + +function setRecentMatchesPaginationItems(items, listNode) { + if (!recentMatchesPagination) { + listNode.innerHTML = items.map((item) => renderRecentMatchCard(item)).join(""); + return; + } + + recentMatchesPagination.items = items; + recentMatchesPagination.page = 1; + renderRecentMatchesPage(listNode); +} + +function renderRecentMatchesPage(listNode) { + const pagination = recentMatchesPagination; + if (!pagination) { + return; + } + + const pageCount = getRecentMatchesPageCount(pagination); + pagination.page = Math.min(Math.max(1, pagination.page), pageCount); + const pageStart = (pagination.page - 1) * pagination.pageSize; + const visibleItems = pagination.items.slice(pageStart, pageStart + pagination.pageSize); + listNode.innerHTML = visibleItems.map((item) => renderRecentMatchCard(item)).join(""); + if (pagination.status) { + pagination.status.textContent = `Pagina ${pagination.page} de ${pageCount}`; + } + if (pagination.previousButton) { + pagination.previousButton.disabled = pagination.page <= 1; + } + if (pagination.nextButton) { + pagination.nextButton.disabled = pagination.page >= pageCount; + } + if (pagination.root) { + pagination.root.hidden = pagination.items.length <= DEFAULT_RECENT_MATCHES_PAGE_SIZE; + } +} + +function getRecentMatchesPageCount(pagination) { + return Math.max(1, Math.ceil(pagination.items.length / pagination.pageSize)); +} + +function normalizeRecentMatchesPageSize(rawValue) { + const pageSize = Number(rawValue); + return RECENT_MATCHES_PAGE_SIZES.includes(pageSize) + ? pageSize + : DEFAULT_RECENT_MATCHES_PAGE_SIZE; +} + function hydrateMonthlyMvp( result, stateNode, diff --git a/frontend/assets/js/partida-actual.js b/frontend/assets/js/partida-actual.js index ba08361..d0e4505 100644 --- a/frontend/assets/js/partida-actual.js +++ b/frontend/assets/js/partida-actual.js @@ -225,9 +225,6 @@ function initializePlayerStats(nodes) {

Cargando estadisticas en vivo...

-

- Jugadores detectados: 0 -

`, ); diff --git a/frontend/partida-actual.html b/frontend/partida-actual.html index ec0d0e4..ecf00ce 100644 --- a/frontend/partida-actual.html +++ b/frontend/partida-actual.html @@ -119,9 +119,14 @@

Jugadores

Estadisticas en vivo

-

- Las estadisticas en vivo apareceran cuando haya datos suficientes. -

+
+

+ Las estadisticas en vivo apareceran cuando haya datos suficientes. +

+

+ Jugadores detectados: 0 +

+
From 664f3f456f001feb659f953ff2f67569d59f1191 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Fri, 22 May 2026 14:56:54 +0200 Subject: [PATCH 33/39] fix: refresh historical snapshots from runner --- backend/app/config.py | 54 +++++--- backend/app/historical_runner.py | 36 ++++- backend/app/historical_snapshots.py | 68 +++++++++- backend/app/historical_storage.py | 10 +- backend/app/player_event_aggregates.py | 7 +- backend/app/postgres_display_storage.py | 13 +- backend/app/rcon_historical_read_model.py | 41 +++--- .../tests/test_historical_snapshot_refresh.py | 126 ++++++++++++++++++ 8 files changed, 296 insertions(+), 59 deletions(-) create mode 100644 backend/tests/test_historical_snapshot_refresh.py diff --git a/backend/app/config.py b/backend/app/config.py index bfc4c38..9a74e41 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -233,20 +233,40 @@ def get_historical_crcon_retry_delay_seconds() -> float: def get_historical_refresh_interval_seconds() -> int: """Return the default interval used by the historical refresh loop.""" - configured_value = os.getenv( + return _read_int_env( "HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS", os.getenv( "HLL_HISTORICAL_REFRESH_INTERVAL_SECONDS", str(DEFAULT_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS), ), + minimum=1, ) - interval_seconds = int(configured_value) - if interval_seconds <= 0: - raise ValueError( - "HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS must be positive." - ) - return interval_seconds + +def _read_int_env(name: str, default_value: str, *, minimum: int) -> int: + """Read one integer env var and keep validation errors actionable.""" + configured_value = os.getenv(name, default_value) + try: + value = int(configured_value) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be an integer.") from error + if value < minimum: + qualifier = "positive" if minimum == 1 else f"at least {minimum}" + raise ValueError(f"{name} must be {qualifier}.") + return value + + +def _read_float_env(name: str, default_value: str, *, minimum: float) -> float: + """Read one float env var and keep validation errors actionable.""" + configured_value = os.getenv(name, default_value) + try: + value = float(configured_value) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be a number.") from error + if value < minimum: + qualifier = "zero or positive" if minimum == 0 else f"at least {minimum}" + raise ValueError(f"{name} must be {qualifier}.") + return value def get_historical_refresh_overlap_hours() -> int: @@ -297,30 +317,20 @@ def get_rcon_request_timeout_seconds() -> float: def get_historical_refresh_max_retries() -> int: """Return the retry count used by the historical refresh loop.""" - configured_value = os.getenv( + return _read_int_env( "HLL_HISTORICAL_REFRESH_MAX_RETRIES", str(DEFAULT_HISTORICAL_REFRESH_MAX_RETRIES), + minimum=0, ) - max_retries = int(configured_value) - if max_retries < 0: - raise ValueError("HLL_HISTORICAL_REFRESH_MAX_RETRIES must be zero or positive.") - - return max_retries -def get_historical_refresh_retry_delay_seconds() -> int: +def get_historical_refresh_retry_delay_seconds() -> float: """Return the wait time between historical refresh retries.""" - configured_value = os.getenv( + return _read_float_env( "HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS", str(DEFAULT_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS), + minimum=0, ) - retry_delay_seconds = int(configured_value) - if retry_delay_seconds < 0: - raise ValueError( - "HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS must be zero or positive." - ) - - return retry_delay_seconds def get_historical_full_snapshot_every_runs() -> int: diff --git a/backend/app/historical_runner.py b/backend/app/historical_runner.py index 49bd5d5..e14442a 100644 --- a/backend/app/historical_runner.py +++ b/backend/app/historical_runner.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import json import time +import traceback from datetime import datetime, timezone from typing import Any @@ -39,7 +40,7 @@ def run_periodic_historical_refresh( *, interval_seconds: int, max_retries: int, - retry_delay_seconds: int, + retry_delay_seconds: float, server_slug: str | None = None, max_pages: int | None = None, page_size: int | None = None, @@ -73,7 +74,7 @@ def run_periodic_historical_refresh( page_size=page_size, run_number=completed_runs, ) - print(json.dumps({"run": completed_runs, **payload}, indent=2), flush=True) + _emit_json_log({"run": completed_runs, **payload}) if max_runs is not None and completed_runs >= max_runs: break @@ -86,7 +87,7 @@ def run_periodic_historical_refresh( def _run_refresh_with_retries( *, max_retries: int, - retry_delay_seconds: int, + retry_delay_seconds: float, server_slug: str | None, max_pages: int | None, page_size: int | None, @@ -182,12 +183,25 @@ def _run_refresh_with_retries( "elo_mmr_result": elo_mmr_result, } except Exception as exc: + failure_payload = { + "event": "historical-refresh-attempt-failed", + "attempt": attempt, + "max_retries": max_retries, + "server_scope": _describe_refresh_scope(server_slug), + "snapshot_scope": _describe_snapshot_scope(server_slug), + "error_type": type(exc).__name__, + "error": str(exc), + "traceback": traceback.format_exc(), + } + _emit_json_log(failure_payload) if attempt > max_retries: return { "status": "error", "attempts_used": attempt, "max_retries": max_retries, + "error_type": type(exc).__name__, "error": str(exc), + "traceback": failure_payload["traceback"], } if retry_delay_seconds > 0: time.sleep(retry_delay_seconds) @@ -202,6 +216,15 @@ def generate_historical_snapshots( generated_at = datetime.now(timezone.utc) full_snapshot_every_runs = get_historical_full_snapshot_every_runs() should_run_full_refresh = bool(server_slug) or run_number % full_snapshot_every_runs == 0 + _emit_json_log( + { + "event": "historical-snapshot-refresh-started", + "run_number": run_number, + "snapshot_step": "full-matrix" if should_run_full_refresh else "priority-prewarm", + "server_slug": server_slug, + "snapshot_scope": _describe_snapshot_scope(server_slug), + } + ) if should_run_full_refresh: result = generate_and_persist_historical_snapshots( server_key=server_slug, @@ -221,6 +244,11 @@ def generate_historical_snapshots( } +def _emit_json_log(payload: dict[str, Any]) -> None: + """Print JSON logs that remain safe for Compose and log collectors.""" + print(json.dumps(payload, ensure_ascii=True, default=str), flush=True) + + def _describe_refresh_scope(server_slug: str | None) -> list[str]: if server_slug: return [server_slug] @@ -353,7 +381,7 @@ def main() -> None: ) parser.add_argument( "--retry-delay", - type=int, + type=float, default=get_historical_refresh_retry_delay_seconds(), help="Seconds to wait between failed attempts.", ) diff --git a/backend/app/historical_snapshots.py b/backend/app/historical_snapshots.py index 8477617..b6254fa 100644 --- a/backend/app/historical_snapshots.py +++ b/backend/app/historical_snapshots.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import sqlite3 from datetime import datetime, timezone from pathlib import Path @@ -135,9 +136,16 @@ def build_historical_server_snapshots( ) -> list[dict[str, object]]: """Build all precomputed historical snapshots required for one server.""" generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) + leaderboard_limit = _normalize_snapshot_limit("leaderboard_limit", leaderboard_limit) + recent_matches_limit = _normalize_snapshot_limit( + "recent_matches_limit", + recent_matches_limit, + ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_SERVER_SUMMARY) snapshots = [_build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path)] for metric in SNAPSHOT_LEADERBOARD_METRICS: + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, metric=metric) snapshots.append( _build_weekly_leaderboard_snapshot( server_key, @@ -147,6 +155,7 @@ def build_historical_server_snapshots( db_path=db_path, ) ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, metric=metric) snapshots.append( _build_monthly_leaderboard_snapshot( server_key, @@ -157,6 +166,7 @@ def build_historical_server_snapshots( ) ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP) snapshots.append( _build_monthly_mvp_snapshot( server_key, @@ -165,6 +175,7 @@ def build_historical_server_snapshots( db_path=db_path, ) ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP_V2) snapshots.append( _build_monthly_mvp_v2_snapshot( server_key, @@ -174,6 +185,7 @@ def build_historical_server_snapshots( ) ) for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES: + _log_snapshot_build_started(server_key, snapshot_type) snapshots.append( _build_player_event_snapshot( server_key, @@ -184,6 +196,7 @@ def build_historical_server_snapshots( ) ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_RECENT_MATCHES) snapshots.append( _build_recent_matches_snapshot( server_key, @@ -205,12 +218,23 @@ def build_priority_historical_snapshots( ) -> list[dict[str, object]]: """Build the minimum warm snapshot set required by the historical UI.""" generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) + leaderboard_limit = _normalize_snapshot_limit("leaderboard_limit", leaderboard_limit) + recent_matches_limit = _normalize_snapshot_limit( + "recent_matches_limit", + recent_matches_limit, + ) snapshots: list[dict[str, object]] = [] for server_key in server_keys: + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_SERVER_SUMMARY) snapshots.append( _build_server_summary_snapshot(server_key, generated_at_value, db_path=db_path) ) for metric in PREWARM_LEADERBOARD_METRICS: + _log_snapshot_build_started( + server_key, + SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, + metric=metric, + ) snapshots.append( _build_weekly_leaderboard_snapshot( server_key, @@ -220,6 +244,11 @@ def build_priority_historical_snapshots( db_path=db_path, ) ) + _log_snapshot_build_started( + server_key, + SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, + metric=metric, + ) snapshots.append( _build_monthly_leaderboard_snapshot( server_key, @@ -229,6 +258,7 @@ def build_priority_historical_snapshots( db_path=db_path, ) ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP) snapshots.append( _build_monthly_mvp_snapshot( server_key, @@ -237,6 +267,7 @@ def build_priority_historical_snapshots( db_path=db_path, ) ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_MONTHLY_MVP_V2) snapshots.append( _build_monthly_mvp_v2_snapshot( server_key, @@ -246,6 +277,7 @@ def build_priority_historical_snapshots( ) ) for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES: + _log_snapshot_build_started(server_key, snapshot_type) snapshots.append( _build_player_event_snapshot( server_key, @@ -255,6 +287,7 @@ def build_priority_historical_snapshots( db_path=db_path, ) ) + _log_snapshot_build_started(server_key, SNAPSHOT_TYPE_RECENT_MATCHES) snapshots.append( _build_recent_matches_snapshot( server_key, @@ -678,7 +711,7 @@ def _get_latest_player_event_month_key( with _connect(resolved_path) as connection: row = connection.execute( f""" - SELECT MAX(substr(occurred_at, 1, 7)) AS latest_month + SELECT MAX(substr(CAST(occurred_at AS TEXT), 1, 7)) AS latest_month FROM player_event_raw_ledger WHERE occurred_at IS NOT NULL AND {where_sql} @@ -706,7 +739,7 @@ def _get_player_event_source_range( MAX(occurred_at) AS source_range_end FROM player_event_raw_ledger WHERE occurred_at IS NOT NULL - AND substr(occurred_at, 1, 7) = ? + AND substr(CAST(occurred_at AS TEXT), 1, 7) = ? AND {where_sql} """, [month_key, *params], @@ -753,3 +786,34 @@ def _as_utc(value: datetime) -> datetime: def _to_iso(value: datetime) -> str: return _as_utc(value).isoformat().replace("+00:00", "Z") + + +def _normalize_snapshot_limit(name: str, value: object) -> int: + try: + limit = int(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be a positive integer.") from error + if limit <= 0: + raise ValueError(f"{name} must be a positive integer.") + return limit + + +def _log_snapshot_build_started( + server_key: str, + snapshot_type: str, + *, + metric: str | None = None, +) -> None: + print( + json.dumps( + { + "event": "historical-snapshot-build-started", + "server_key": server_key, + "snapshot_type": snapshot_type, + "metric": metric, + }, + ensure_ascii=True, + default=str, + ), + flush=True, + ) diff --git a/backend/app/historical_storage.py b/backend/app/historical_storage.py index 4e5be7b..db7dd57 100644 --- a/backend/app/historical_storage.py +++ b/backend/app/historical_storage.py @@ -1842,7 +1842,7 @@ def list_monthly_mvp_v2_ranking( FROM player_event_raw_ledger WHERE event_type = 'player_kill_summary' AND occurred_at IS NOT NULL - AND substr(occurred_at, 1, 7) = ? + AND substr(CAST(occurred_at AS TEXT), 1, 7) = ? AND {event_scope_sql} AND killer_player_key IS NOT NULL AND victim_player_key IS NOT NULL @@ -1863,7 +1863,7 @@ def list_monthly_mvp_v2_ranking( FROM player_event_raw_ledger WHERE event_type = 'player_death_summary' AND occurred_at IS NOT NULL - AND substr(occurred_at, 1, 7) = ? + AND substr(CAST(occurred_at AS TEXT), 1, 7) = ? AND {event_scope_sql} AND killer_player_key IS NOT NULL AND victim_player_key IS NOT NULL @@ -1901,7 +1901,7 @@ def list_monthly_mvp_v2_ranking( FROM player_event_raw_ledger WHERE event_type = 'player_kill_summary' AND occurred_at IS NOT NULL - AND substr(occurred_at, 1, 7) = ? + AND substr(CAST(occurred_at AS TEXT), 1, 7) = ? AND {event_scope_sql} AND killer_player_key IS NOT NULL AND victim_player_key IS NOT NULL @@ -2020,7 +2020,7 @@ def _get_monthly_player_event_coverage( with _connect(db_path) as connection: latest_row = connection.execute( f""" - SELECT MAX(substr(occurred_at, 1, 7)) AS latest_month_key + SELECT MAX(substr(CAST(occurred_at AS TEXT), 1, 7)) AS latest_month_key FROM player_event_raw_ledger WHERE occurred_at IS NOT NULL AND {scope_sql} @@ -2035,7 +2035,7 @@ def _get_monthly_player_event_coverage( MAX(occurred_at) AS source_range_end FROM player_event_raw_ledger WHERE occurred_at IS NOT NULL - AND substr(occurred_at, 1, 7) = ? + AND substr(CAST(occurred_at AS TEXT), 1, 7) = ? AND {scope_sql} """, [month_key, *scope_params], diff --git a/backend/app/player_event_aggregates.py b/backend/app/player_event_aggregates.py index 8229213..e6c1592 100644 --- a/backend/app/player_event_aggregates.py +++ b/backend/app/player_event_aggregates.py @@ -108,7 +108,10 @@ def list_net_duel_summaries( COALESCE(SUM(net_value), 0) AS net_duel_value FROM duel_pairs GROUP BY player_a_key, player_a_name, player_b_key, player_b_name - ORDER BY ABS(net_duel_value) DESC, total_encounters DESC, player_a_name ASC, player_b_name ASC + ORDER BY ABS(COALESCE(SUM(net_value), 0)) DESC, + COALESCE(SUM(event_value), 0) DESC, + player_a_name ASC, + player_b_name ASC LIMIT ? """, [*params, limit], @@ -239,7 +242,7 @@ def _build_common_where( clauses.append("server_slug = ?") params.append(server_slug.strip()) if month: - clauses.append("substr(COALESCE(occurred_at, ''), 1, 7) = ?") + clauses.append("substr(COALESCE(CAST(occurred_at AS TEXT), ''), 1, 7) = ?") params.append(month.strip()) if external_match_id: clauses.append("external_match_id = ?") diff --git a/backend/app/postgres_display_storage.py b/backend/app/postgres_display_storage.py index af06a6b..36898b1 100644 --- a/backend/app/postgres_display_storage.py +++ b/backend/app/postgres_display_storage.py @@ -218,7 +218,12 @@ def persist_snapshot_record(snapshot: Mapping[str, object]) -> HistoricalSnapsho metric = str(snapshot.get("metric") or "") window = str(snapshot.get("window") or "") payload = snapshot.get("payload") - payload_json = json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + payload_json = json.dumps( + payload, + ensure_ascii=True, + separators=(",", ":"), + default=_json_payload_default, + ) with connect_postgres() as connection: connection.execute( """ @@ -904,6 +909,12 @@ def _iso(value: object) -> str | None: return text or None +def _json_payload_default(value: object) -> str: + if isinstance(value, datetime): + return _iso(value) or "" + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + + def _parse_datetime(value: str | None) -> datetime | None: if not value: return None diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index 299ff5d..9434ba7 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -582,11 +582,14 @@ def _minutes_since_timestamp(timestamp: str | None) -> int | None: def _parse_datetime(value: object) -> datetime | None: - if not isinstance(value, str) or not value.strip(): - return None - try: - parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) - except ValueError: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + else: return None if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) @@ -594,31 +597,23 @@ def _parse_datetime(value: object) -> datetime | None: def _calculate_coverage_hours( - first_sample_at: str | None, - last_sample_at: str | None, + first_sample_at: object, + last_sample_at: object, ) -> float | None: - if not first_sample_at or not last_sample_at: + first_point = _parse_datetime(first_sample_at) + last_point = _parse_datetime(last_sample_at) + if first_point is None or last_point is None: return None - first_point = datetime.fromisoformat(first_sample_at.replace("Z", "+00:00")) - last_point = datetime.fromisoformat(last_sample_at.replace("Z", "+00:00")) - if first_point.tzinfo is None: - first_point = first_point.replace(tzinfo=timezone.utc) - if last_point.tzinfo is None: - last_point = last_point.replace(tzinfo=timezone.utc) - delta = last_point.astimezone(timezone.utc) - first_point.astimezone(timezone.utc) + delta = last_point - first_point return round(delta.total_seconds() / 3600, 2) def _calculate_duration_seconds(first_seen_at: object, last_seen_at: object) -> int | None: - if not isinstance(first_seen_at, str) or not isinstance(last_seen_at, str): + first_point = _parse_datetime(first_seen_at) + last_point = _parse_datetime(last_seen_at) + if first_point is None or last_point is None: return None - first_point = datetime.fromisoformat(first_seen_at.replace("Z", "+00:00")) - last_point = datetime.fromisoformat(last_seen_at.replace("Z", "+00:00")) - if first_point.tzinfo is None: - first_point = first_point.replace(tzinfo=timezone.utc) - if last_point.tzinfo is None: - last_point = last_point.replace(tzinfo=timezone.utc) - return max(0, int((last_point.astimezone(timezone.utc) - first_point.astimezone(timezone.utc)).total_seconds())) + return max(0, int((last_point - first_point).total_seconds())) def _calculate_match_duration_seconds(item: dict[str, object]) -> int | None: diff --git a/backend/tests/test_historical_snapshot_refresh.py b/backend/tests/test_historical_snapshot_refresh.py new file mode 100644 index 0000000..06c8c47 --- /dev/null +++ b/backend/tests/test_historical_snapshot_refresh.py @@ -0,0 +1,126 @@ +"""Regression coverage for historical snapshot runner refreshes.""" + +from __future__ import annotations + +import io +import json +import os +import unittest +from contextlib import nullcontext, redirect_stdout +from datetime import datetime, timezone +from unittest.mock import patch + +from app.config import ( + get_historical_refresh_interval_seconds, + get_historical_refresh_max_retries, + get_historical_refresh_retry_delay_seconds, +) +from app.historical_runner import _run_refresh_with_retries, run_periodic_historical_refresh +from app.historical_snapshots import _normalize_snapshot_limit +from app.postgres_display_storage import _json_payload_default +from app.rcon_historical_read_model import ( + _calculate_coverage_hours, + _calculate_duration_seconds, +) + + +class HistoricalSnapshotRefreshTests(unittest.TestCase): + def test_runner_numeric_env_values_are_parsed_before_use(self) -> None: + with patch.dict( + os.environ, + { + "HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS": "300", + "HLL_HISTORICAL_REFRESH_MAX_RETRIES": "4", + "HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS": "0.5", + }, + clear=False, + ): + self.assertEqual(get_historical_refresh_interval_seconds(), 300) + self.assertEqual(get_historical_refresh_max_retries(), 4) + self.assertEqual(get_historical_refresh_retry_delay_seconds(), 0.5) + + def test_runner_numeric_env_values_fail_with_clear_names(self) -> None: + with patch.dict( + os.environ, + {"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS": "hourly"}, + clear=False, + ): + with self.assertRaisesRegex( + ValueError, + "HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS must be an integer", + ): + get_historical_refresh_interval_seconds() + + def test_rcon_coverage_accepts_postgres_datetime_values(self) -> None: + start = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc) + end = datetime(2026, 5, 21, 11, 30, tzinfo=timezone.utc) + + self.assertEqual(_calculate_coverage_hours(start, end), 1.5) + self.assertEqual(_calculate_duration_seconds(start, end), 5400) + + def test_snapshot_limits_are_numeric_before_snapshot_queries(self) -> None: + self.assertEqual(_normalize_snapshot_limit("recent_matches_limit", "10"), 10) + with self.assertRaisesRegex(ValueError, "recent_matches_limit"): + _normalize_snapshot_limit("recent_matches_limit", "ten") + + def test_postgres_snapshot_payload_serializes_datetime_values(self) -> None: + payload = { + "captured_at": datetime(2026, 5, 21, 20, 12, 54, tzinfo=timezone.utc), + } + + self.assertEqual( + json.loads(json.dumps(payload, default=_json_payload_default)), + {"captured_at": "2026-05-21T20:12:54Z"}, + ) + + def test_runner_failure_log_includes_exception_type_and_traceback(self) -> None: + stream = io.StringIO() + with ( + patch("app.historical_runner.backend_writer_lock", return_value=nullcontext()), + patch( + "app.historical_runner._run_primary_rcon_capture", + side_effect=TypeError("bad timestamp"), + ), + redirect_stdout(stream), + ): + result = _run_refresh_with_retries( + max_retries=0, + retry_delay_seconds=0, + server_slug=None, + max_pages=None, + page_size=None, + run_number=1, + ) + + self.assertEqual(result["status"], "error") + self.assertEqual(result["error_type"], "TypeError") + self.assertIn("Traceback", result["traceback"]) + self.assertIn('"event": "historical-refresh-attempt-failed"', stream.getvalue()) + + def test_runner_success_log_serializes_datetime_values(self) -> None: + stream = io.StringIO() + with ( + patch( + "app.historical_runner._run_refresh_with_retries", + return_value={ + "status": "ok", + "rcon_capture_result": { + "captured_at": datetime(2026, 5, 22, tzinfo=timezone.utc), + }, + }, + ), + redirect_stdout(stream), + ): + run_periodic_historical_refresh( + interval_seconds=1, + max_retries=0, + retry_delay_seconds=0, + max_runs=1, + ) + + self.assertIn('"status": "ok"', stream.getvalue()) + self.assertIn('"captured_at": "2026-05-22 00:00:00+00:00"', stream.getvalue()) + + +if __name__ == "__main__": + unittest.main() From d741028098a20437aba65bfdc86439b7c815c854 Mon Sep 17 00:00:00 2001 From: devRaGonSa <97627393+devRaGonSa@users.noreply.github.com> Date: Fri, 22 May 2026 16:35:44 +0200 Subject: [PATCH 34/39] fix: add rcon materialized leaderboard snapshots --- backend/app/rcon_historical_leaderboards.py | 403 ++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 backend/app/rcon_historical_leaderboards.py diff --git a/backend/app/rcon_historical_leaderboards.py b/backend/app/rcon_historical_leaderboards.py new file mode 100644 index 0000000..94b05a5 --- /dev/null +++ b/backend/app/rcon_historical_leaderboards.py @@ -0,0 +1,403 @@ +"""Leaderboard read model over materialized RCON/AdminLog match stats.""" + +from __future__ import annotations + +from contextlib import closing +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Literal + +from .config import get_storage_path, use_postgres_rcon_storage +from .historical_storage import ALL_SERVERS_SLUG +from .rcon_admin_log_materialization import ( + MATCH_RESULT_SOURCE, + initialize_rcon_materialized_storage, +) +from .sqlite_utils import connect_sqlite_readonly + +LeaderboardTimeframe = Literal["weekly", "monthly"] +LeaderboardMetric = Literal["kills", "deaths", "matches_over_100_kills", "support"] + + +def list_rcon_materialized_leaderboard( + *, + server_key: str | None = None, + timeframe: str = "weekly", + metric: str = "kills", + limit: int = 10, + db_path: Path | None = None, +) -> dict[str, object]: + """Return a leaderboard built from materialized RCON/AdminLog player stats. + + RCON/AdminLog materialization currently has reliable kill/death/teamkill counters, + but not public-scoreboard support points. For support, return an explicitly empty + supported payload rather than falling back to unrelated public scoreboard storage. + """ + + normalized_timeframe = _normalize_timeframe(timeframe) + normalized_metric = _normalize_metric(metric) + normalized_limit = max(1, int(limit or 10)) + window = _build_window(normalized_timeframe) + + if normalized_metric == "support": + return _empty_payload( + server_key=server_key, + timeframe=normalized_timeframe, + metric=normalized_metric, + limit=normalized_limit, + window=window, + reason="rcon-materialized-stats-do-not-include-support-score", + ) + + resolved_path = initialize_rcon_materialized_storage(db_path=db_path) + connection_scope = _connect_scope(resolved_path, db_path=db_path) + with connection_scope as connection: + rows = _fetch_leaderboard_rows( + connection, + server_key=server_key, + metric=normalized_metric, + limit=normalized_limit, + window_start=window["start"], + window_end=window["end"], + ) + counts = _fetch_match_counts( + connection, + server_key=server_key, + timeframe=normalized_timeframe, + window_start=window["start"], + window_end=window["end"], + ) + source_range = _fetch_source_range( + connection, + server_key=server_key, + window_start=window["start"], + window_end=window["end"], + ) + + items = [_build_item(row, index=index + 1) for index, row in enumerate(rows)] + return { + "source": "rcon-materialized-admin-log-leaderboard", + "server_key": server_key, + "metric": normalized_metric, + "limit": normalized_limit, + "window_days": window["days"], + "window_start": _to_iso(window["start"]), + "window_end": _to_iso(window["end"]), + "window_kind": window["kind"], + "window_label": window["label"], + "uses_fallback": False, + "selection_reason": "rcon-materialized-current-window", + "current_week_start": _to_iso(_week_start(window["end"])), + "current_week_closed_matches": counts["current_week_closed_matches"], + "previous_week_closed_matches": counts["previous_week_closed_matches"], + "current_month_start": _to_iso(_month_start(window["end"])), + "current_month_closed_matches": counts["current_month_closed_matches"], + "previous_month_closed_matches": counts["previous_month_closed_matches"], + "sufficient_sample": { + "minimum_closed_matches": 1, + "current_week_closed_matches": counts["current_week_closed_matches"], + "current_week_has_sufficient_sample": counts["current_week_closed_matches"] >= 1, + "is_early_week": False, + "fallback_max_weekday": 2, + }, + "source_range_start": _to_iso(source_range[0]) if source_range[0] else None, + "source_range_end": _to_iso(source_range[1]) if source_range[1] else None, + "items": items, + } + + +def _fetch_leaderboard_rows( + connection: object, + *, + server_key: str | None, + metric: str, + limit: int, + window_start: datetime, + window_end: datetime, +) -> list[dict[str, object]]: + scope_sql, scope_params = _build_scope_sql(server_key) + metric_sql = { + "kills": "SUM(COALESCE(stats.kills, 0))", + "deaths": "SUM(COALESCE(stats.deaths, 0))", + "matches_over_100_kills": "SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END)", + }[metric] + having_sql = f"HAVING {metric_sql} > 0" + params: list[object] = [ + _to_iso(window_start), + _to_iso(window_end), + *scope_params, + limit, + ] + rows = connection.execute( + f""" + SELECT + stats.player_id, + stats.player_name, + {metric_sql} AS metric_value, + COUNT(DISTINCT stats.match_key) AS matches_considered, + SUM(COALESCE(stats.kills, 0)) AS kills, + SUM(COALESCE(stats.deaths, 0)) AS deaths, + SUM(COALESCE(stats.teamkills, 0)) AS teamkills + FROM rcon_match_player_stats AS stats + INNER JOIN rcon_materialized_matches AS matches + ON matches.target_key = stats.target_key + AND matches.match_key = stats.match_key + WHERE matches.source_basis = ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) <= ? + {scope_sql} + AND TRIM(COALESCE(stats.player_name, '')) != '' + GROUP BY stats.player_id, stats.player_name + {having_sql} + ORDER BY metric_value DESC, matches_considered DESC, stats.player_name ASC + LIMIT ? + """, + [MATCH_RESULT_SOURCE, *params], + ).fetchall() + return [dict(row) for row in rows] + + +def _fetch_match_counts( + connection: object, + *, + server_key: str | None, + timeframe: str, + window_start: datetime, + window_end: datetime, +) -> dict[str, int]: + current_week_start = _week_start(window_end) + previous_week_start = current_week_start - timedelta(days=7) + current_month_start = _month_start(window_end) + previous_month_start = _previous_month_start(current_month_start) + return { + "current_week_closed_matches": _count_matches( + connection, + server_key=server_key, + start=current_week_start, + end=window_end, + ), + "previous_week_closed_matches": _count_matches( + connection, + server_key=server_key, + start=previous_week_start, + end=current_week_start, + ), + "current_month_closed_matches": _count_matches( + connection, + server_key=server_key, + start=current_month_start, + end=window_end, + ), + "previous_month_closed_matches": _count_matches( + connection, + server_key=server_key, + start=previous_month_start, + end=current_month_start, + ), + } + + +def _fetch_source_range( + connection: object, + *, + server_key: str | None, + window_start: datetime, + window_end: datetime, +) -> tuple[datetime | None, datetime | None]: + scope_sql, scope_params = _build_scope_sql(server_key, table_alias="matches") + row = connection.execute( + f""" + SELECT + MIN(COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))) AS source_range_start, + MAX(COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT))) AS source_range_end + FROM rcon_materialized_matches AS matches + WHERE matches.source_basis = ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) <= ? + {scope_sql} + """, + [MATCH_RESULT_SOURCE, _to_iso(window_start), _to_iso(window_end), *scope_params], + ).fetchone() + if not row: + return None, None + return _parse_datetime(row["source_range_start"]), _parse_datetime(row["source_range_end"]) + + +def _count_matches( + connection: object, + *, + server_key: str | None, + start: datetime, + end: datetime, +) -> int: + scope_sql, scope_params = _build_scope_sql(server_key, table_alias="matches") + row = connection.execute( + f""" + SELECT COUNT(*) AS count + FROM rcon_materialized_matches AS matches + WHERE matches.source_basis = ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ? + AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) < ? + {scope_sql} + """, + [MATCH_RESULT_SOURCE, _to_iso(start), _to_iso(end), *scope_params], + ).fetchone() + return int(row["count"] or 0) if row else 0 + + +def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]: + kills = _coerce_int(row.get("kills")) + deaths = _coerce_int(row.get("deaths")) + return { + "ranking_position": index, + "player": { + "id": row.get("player_id"), + "name": row.get("player_name"), + }, + "player_id": row.get("player_id"), + "player_name": row.get("player_name"), + "metric_value": _coerce_int(row.get("metric_value")), + "matches_considered": _coerce_int(row.get("matches_considered")), + "kills": kills, + "deaths": deaths, + "teamkills": _coerce_int(row.get("teamkills")), + "kd_ratio": round(kills / deaths, 2) if deaths else float(kills), + } + + +def _build_scope_sql( + server_key: str | None, + *, + table_alias: str = "matches", +) -> tuple[str, list[object]]: + if not server_key or server_key == ALL_SERVERS_SLUG: + return "", [] + return f"AND ({table_alias}.target_key = ? OR {table_alias}.external_server_id = ?)", [ + server_key, + server_key, + ] + + +def _connect_scope(resolved_path: Path, *, db_path: Path | None): + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + return connect_postgres_compat() + return closing(connect_sqlite_readonly(resolved_path)) + + +def _empty_payload( + *, + server_key: str | None, + timeframe: str, + metric: str, + limit: int, + window: dict[str, object], + reason: str, +) -> dict[str, object]: + return { + "source": "rcon-materialized-admin-log-leaderboard", + "server_key": server_key, + "metric": metric, + "limit": limit, + "window_days": window["days"], + "window_start": _to_iso(window["start"]), + "window_end": _to_iso(window["end"]), + "window_kind": window["kind"], + "window_label": window["label"], + "uses_fallback": False, + "selection_reason": reason, + "current_week_start": _to_iso(_week_start(window["end"])), + "current_week_closed_matches": 0, + "previous_week_closed_matches": 0, + "current_month_start": _to_iso(_month_start(window["end"])), + "current_month_closed_matches": 0, + "previous_month_closed_matches": 0, + "sufficient_sample": { + "minimum_closed_matches": 1, + "current_week_closed_matches": 0, + "current_week_has_sufficient_sample": False, + "is_early_week": False, + "fallback_max_weekday": 2, + }, + "source_range_start": None, + "source_range_end": None, + "items": [], + } + + +def _build_window(timeframe: str) -> dict[str, object]: + now = datetime.now(timezone.utc) + if timeframe == "monthly": + start = _month_start(now) + return { + "start": start, + "end": now, + "days": max(1, (now.date() - start.date()).days + 1), + "kind": "current-month", + "label": "Mes actual", + } + start = _week_start(now) + return { + "start": start, + "end": now, + "days": max(1, (now.date() - start.date()).days + 1), + "kind": "current-week", + "label": "Semana actual", + } + + +def _week_start(value: datetime) -> datetime: + point = value.astimezone(timezone.utc) + start = point - timedelta(days=point.weekday()) + return start.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _month_start(value: datetime) -> datetime: + point = value.astimezone(timezone.utc) + return point.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +def _previous_month_start(current_month_start: datetime) -> datetime: + previous_month_end = current_month_start - timedelta(days=1) + return _month_start(previous_month_end) + + +def _normalize_timeframe(value: str) -> LeaderboardTimeframe: + return "monthly" if str(value or "").strip().lower() == "monthly" else "weekly" + + +def _normalize_metric(value: str) -> LeaderboardMetric: + normalized = str(value or "kills").strip().lower() + if normalized in {"kills", "deaths", "matches_over_100_kills", "support"}: + return normalized # type: ignore[return-value] + return "kills" + + +def _coerce_int(value: object) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _parse_datetime(value: object) -> datetime | None: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + else: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _to_iso(value: object) -> str: + parsed = _parse_datetime(value) + if parsed is None: + parsed = datetime.now(timezone.utc) + return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") From c0be3995e789185b4338026e0257a48794973d6a Mon Sep 17 00:00:00 2001 From: devRaGonSa <97627393+devRaGonSa@users.noreply.github.com> Date: Fri, 22 May 2026 16:38:27 +0200 Subject: [PATCH 35/39] fix: expose rcon materialized leaderboard payload --- backend/app/rcon_historical_leaderboards.py | 95 +++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/backend/app/rcon_historical_leaderboards.py b/backend/app/rcon_historical_leaderboards.py index 94b05a5..04231d2 100644 --- a/backend/app/rcon_historical_leaderboards.py +++ b/backend/app/rcon_historical_leaderboards.py @@ -19,6 +19,89 @@ LeaderboardTimeframe = Literal["weekly", "monthly"] LeaderboardMetric = Literal["kills", "deaths", "matches_over_100_kills", "support"] +def build_rcon_materialized_leaderboard_snapshot_payload( + *, + server_id: str | None = None, + timeframe: str = "weekly", + metric: str = "kills", + limit: int = 10, +) -> dict[str, object]: + """Return an API payload for RCON-backed leaderboard snapshots. + + This is a runtime fast read over the materialized AdminLog tables. It intentionally + avoids the old public-scoreboard fallback because the UI is running in RCON mode. + """ + + normalized_timeframe = _normalize_timeframe(timeframe) + normalized_metric = _normalize_metric(metric) + result = list_rcon_materialized_leaderboard( + server_key=server_id, + timeframe=normalized_timeframe, + metric=normalized_metric, + limit=limit, + ) + items = list(result.get("items") or [])[:limit] + return { + "status": "ok", + "data": { + "title": _build_title( + metric=normalized_metric, + timeframe=normalized_timeframe, + server_id=server_id, + ), + "context": f"historical-{normalized_timeframe}-leaderboard-snapshot", + "source": "rcon-materialized-admin-log-leaderboard", + "server_slug": server_id, + "timeframe": normalized_timeframe, + "metric": normalized_metric, + "found": True, + "snapshot_status": "ready", + "missing_reason": None, + "request_path_policy": "runtime-rcon-materialized-fast-path", + "generation_policy": "runtime-materialized-read", + "generated_at": _to_iso(datetime.now(timezone.utc)), + "source_range_start": result.get("source_range_start"), + "source_range_end": result.get("source_range_end"), + "is_stale": False, + "freshness": "runtime", + "window_days": result.get("window_days"), + "window_start": result.get("window_start"), + "window_end": result.get("window_end"), + "window_kind": result.get("window_kind"), + "window_label": result.get("window_label"), + "uses_fallback": False, + "selection_reason": result.get("selection_reason"), + "current_week_start": result.get("current_week_start"), + "current_week_closed_matches": result.get("current_week_closed_matches"), + "previous_week_closed_matches": result.get("previous_week_closed_matches"), + "current_month_start": result.get("current_month_start"), + "current_month_closed_matches": result.get("current_month_closed_matches"), + "previous_month_closed_matches": result.get("previous_month_closed_matches"), + "sufficient_sample": result.get("sufficient_sample"), + "snapshot_limit": result.get("limit"), + "limit": limit, + "runtime_enrichment": { + "applied": False, + "reason": None, + }, + "primary_source": "rcon", + "selected_source": "rcon", + "fallback_used": False, + "fallback_reason": None, + "source_attempts": [ + { + "source": "rcon", + "role": "primary", + "status": "success", + "reason": "leaderboard-served-by-rcon-materialized-admin-log", + "message": None, + } + ], + "items": items, + }, + } + + def list_rcon_materialized_leaderboard( *, server_key: str | None = None, @@ -374,6 +457,18 @@ def _normalize_metric(value: str) -> LeaderboardMetric: return "kills" +def _build_title(*, metric: str, timeframe: str, server_id: str | None) -> str: + timeframe_label = "mensual" if timeframe == "monthly" else "semanal" + scope = "totales" if server_id == ALL_SERVERS_SLUG else "por servidor" + metric_label = { + "kills": "Top kills", + "deaths": "Top muertes", + "matches_over_100_kills": "Partidas 100+ kills", + "support": "Top soporte", + }.get(metric, "Top kills") + return f"Snapshot {metric_label} {timeframe_label} {scope}" + + def _coerce_int(value: object) -> int: try: return int(value or 0) From 5d3288f0f66828282f0f200c03777ff9c49239ee Mon Sep 17 00:00:00 2001 From: devRaGonSa <97627393+devRaGonSa@users.noreply.github.com> Date: Fri, 22 May 2026 16:40:03 +0200 Subject: [PATCH 36/39] fix: route historical leaderboard snapshots to rcon read model --- backend/app/routes.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/app/routes.py b/backend/app/routes.py index 87b9a21..c906413 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -5,6 +5,7 @@ from __future__ import annotations from http import HTTPStatus from urllib.parse import parse_qs, urlparse +from .config import get_historical_data_source_kind from .payloads import ( build_community_payload, build_current_match_kill_feed_payload, @@ -40,6 +41,7 @@ from .payloads import ( build_weekly_leaderboard_payload, build_weekly_top_kills_payload, ) +from .rcon_historical_leaderboards import build_rcon_materialized_leaderboard_snapshot_payload from .scoreboard_origins import get_trusted_public_scoreboard_origin @@ -199,6 +201,13 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") if timeframe not in {"weekly", "monthly"}: return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter") + if get_historical_data_source_kind() == "rcon": + return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe=timeframe, + ) return HTTPStatus.OK, build_leaderboard_snapshot_payload( limit=limit, server_id=server_id, @@ -215,6 +224,13 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] metric = params.get("metric", ["kills"])[0] if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") + if get_historical_data_source_kind() == "rcon": + return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe="monthly", + ) return HTTPStatus.OK, build_monthly_leaderboard_snapshot_payload( limit=limit, server_id=server_id, @@ -265,6 +281,13 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] metric = params.get("metric", ["kills"])[0] if metric not in {"kills", "deaths", "support", "matches_over_100_kills"}: return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter") + if get_historical_data_source_kind() == "rcon": + return HTTPStatus.OK, build_rcon_materialized_leaderboard_snapshot_payload( + limit=limit, + server_id=server_id, + metric=metric, + timeframe="weekly", + ) return HTTPStatus.OK, build_weekly_leaderboard_snapshot_payload( limit=limit, server_id=server_id, From a215b382db62a1ba1ae255a474ce14358eb9b77f Mon Sep 17 00:00:00 2001 From: devRaGonSa <97627393+devRaGonSa@users.noreply.github.com> Date: Fri, 22 May 2026 23:41:05 +0200 Subject: [PATCH 37/39] fix: serialize manual rcon capture output --- backend/app/rcon_historical_worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/rcon_historical_worker.py b/backend/app/rcon_historical_worker.py index 00f2f10..d308e7a 100644 --- a/backend/app/rcon_historical_worker.py +++ b/backend/app/rcon_historical_worker.py @@ -528,7 +528,7 @@ def main(argv: Iterable[str] | None = None) -> int: if args.mode == "capture": result = run_rcon_historical_capture(target_key=args.target_key) - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, default=_json_default)) return 0 if args.interval <= 0: From 0d061803a0c923ce2122c9427cbc7a7a14657f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Gonz=C3=A1lez?= Date: Sat, 23 May 2026 13:51:16 +0200 Subject: [PATCH 38/39] feat: add rcon historical backfill for leaderboard windows --- .../TASK-rcon-admin-log-monthly-backfill.md | 78 +++ backend/README.md | 13 + backend/app/config.py | 30 ++ backend/app/historical_snapshots.py | 49 +- backend/app/rcon_historical_backfill.py | 484 ++++++++++++++++++ backend/app/rcon_historical_leaderboards.py | 192 +++++-- .../tests/test_rcon_historical_backfill.py | 171 +++++++ docker-compose.yml | 1 + docs/historical-rcon-backfill.md | 57 +++ 9 files changed, 1017 insertions(+), 58 deletions(-) create mode 100644 ai/tasks/done/TASK-rcon-admin-log-monthly-backfill.md create mode 100644 backend/app/rcon_historical_backfill.py create mode 100644 backend/tests/test_rcon_historical_backfill.py create mode 100644 docs/historical-rcon-backfill.md diff --git a/ai/tasks/done/TASK-rcon-admin-log-monthly-backfill.md b/ai/tasks/done/TASK-rcon-admin-log-monthly-backfill.md new file mode 100644 index 0000000..b370287 --- /dev/null +++ b/ai/tasks/done/TASK-rcon-admin-log-monthly-backfill.md @@ -0,0 +1,78 @@ +--- +id: TASK-rcon-admin-log-monthly-backfill +title: Add RCON AdminLog historical backfill +status: in-progress +type: backend +team: Backend Senior +supporting_teams: [Arquitecto Python] +roadmap_item: historical-rcon +priority: high +--- + +# TASK-rcon-admin-log-monthly-backfill - Add RCON AdminLog historical backfill + +## Goal + +Add an explicit RCON/AdminLog backfill CLI that can populate materialized closed matches for recent-match and leaderboard windows, and align monthly RCON leaderboard snapshots with the previous-month day 1-7 policy. + +## Context + +HLL Vietnam runs historical data in RCON-first mode. Prospective AdminLog capture exists, but fresh databases need an operator-run backfill path to recover recent closed matches and produce reliable weekly/monthly snapshots without changing web request startup behavior. + +## Steps + +1. Inspect the listed files first. +2. Add the scoped backfill CLI and window policy helpers. +3. Integrate the monthly fallback policy into RCON-backed snapshots. +4. Add focused tests and documentation. +5. Validate with the documented Python checks and Docker checks where available. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `backend/README.md` +- `backend/app/rcon_admin_log_ingestion.py` +- `backend/app/rcon_admin_log_materialization.py` +- `backend/app/historical_runner.py` +- `backend/app/rcon_historical_leaderboards.py` + +## Expected Files to Modify + +- `backend/app/rcon_historical_backfill.py` +- `backend/app/rcon_historical_leaderboards.py` +- `backend/app/config.py` +- `backend/README.md` or `docs/historical-rcon-backfill.md` +- `docker-compose.yml` +- focused tests under `backend/tests/` + +## Constraints + +- Do not touch unrelated UI layout or current-match live page. +- Keep normal backend startup free from long blocking backfill work. +- Keep inserts idempotent/deduplicated and do not remove existing data. +- Keep PostgreSQL compatibility and SQLite compatibility for tests. +- Do not reintroduce `comunidad-hispana-03` by default. +- Avoid public scoreboard fallback for RCON leaderboards when materialized RCON data exists. + +## Validation + +- `python -m compileall backend/app` +- `python -m unittest discover -s backend/tests -p "*historical*"` +- `python -m unittest discover -s backend/tests -p "*rcon*"` +- `git diff --check` +- Docker Compose build/run checks when available in the environment. + +## Outcome + +Implemented. + +- Added `app.rcon_historical_backfill` as an explicit operator CLI. +- Added RCON monthly day 1-7 previous-month policy and weekly sufficient-sample fallback metadata. +- Routed persisted RCON leaderboard snapshot generation through the materialized RCON read model. +- Added Docker/README documentation and focused unittest coverage. +- Docker dry-run passed. A real backfill run was started after stopping writer services; the first attempt correctly reported a busy writer lock, and the second run inserted additional materialized data before the command timeout required stopping the one-off container. Advanced services were restarted afterwards. + +## Change Budget + +- This task is expected to exceed the default line budget because it introduces a new operator CLI plus tests and docs, but changes should remain limited to backend historical RCON surfaces. diff --git a/backend/README.md b/backend/README.md index 49d2505..25e74b9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -71,6 +71,10 @@ Variables opcionales: - `HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS` - `HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES` - `HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS` +- `HLL_RCON_BACKFILL_CHUNK_HOURS` +- `HLL_RCON_BACKFILL_SLEEP_SECONDS` +- `HLL_RCON_BACKFILL_MAX_DAYS_BACK` +- `HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES` - `HLL_BACKEND_SQLITE_WRITER_TIMEOUT_SECONDS` - `HLL_BACKEND_SQLITE_BUSY_TIMEOUT_MS` - `HLL_BACKEND_WRITER_LOCK_TIMEOUT_SECONDS` @@ -466,6 +470,15 @@ docker compose exec backend python -m app.rcon_admin_log_ingestion --minutes 144 docker compose exec backend python -m app.rcon_historical_worker capture ``` +Backfill historico RCON/AdminLog: + +- runbook: `docs/historical-rcon-backfill.md` +- ejemplo seco: + + ```powershell + docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-recent-matches 100 --servers comunidad-hispana-01,comunidad-hispana-02 --dry-run + ``` + Comandos manuales desde `backend/`: ```powershell diff --git a/backend/app/config.py b/backend/app/config.py index 9a74e41..fa8f447 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -35,6 +35,9 @@ DEFAULT_PLAYER_EVENT_REFRESH_RETRY_DELAY_SECONDS = 30 DEFAULT_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS = 600 DEFAULT_RCON_HISTORICAL_CAPTURE_MAX_RETRIES = 2 DEFAULT_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS = 15 +DEFAULT_RCON_BACKFILL_CHUNK_HOURS = 6 +DEFAULT_RCON_BACKFILL_SLEEP_SECONDS = 1.0 +DEFAULT_RCON_BACKFILL_MAX_DAYS_BACK = 45 DEFAULT_SQLITE_WRITER_TIMEOUT_SECONDS = 30.0 DEFAULT_SQLITE_BUSY_TIMEOUT_MS = 30000 DEFAULT_WRITER_LOCK_TIMEOUT_SECONDS = 120.0 @@ -484,6 +487,33 @@ def get_rcon_historical_capture_retry_delay_seconds() -> int: return retry_delay_seconds +def get_rcon_backfill_chunk_hours() -> int: + """Return the AdminLog backfill chunk size in hours.""" + return _read_int_env( + "HLL_RCON_BACKFILL_CHUNK_HOURS", + str(DEFAULT_RCON_BACKFILL_CHUNK_HOURS), + minimum=1, + ) + + +def get_rcon_backfill_sleep_seconds() -> float: + """Return the delay between AdminLog backfill RCON requests.""" + return _read_float_env( + "HLL_RCON_BACKFILL_SLEEP_SECONDS", + str(DEFAULT_RCON_BACKFILL_SLEEP_SECONDS), + minimum=0, + ) + + +def get_rcon_backfill_max_days_back() -> int: + """Return the maximum AdminLog backfill lookback horizon in days.""" + return _read_int_env( + "HLL_RCON_BACKFILL_MAX_DAYS_BACK", + str(DEFAULT_RCON_BACKFILL_MAX_DAYS_BACK), + minimum=1, + ) + + def get_a2s_targets_payload() -> str | None: """Return the optional JSON payload that overrides local A2S targets.""" raw_payload = os.getenv(DEFAULT_A2S_TARGETS_ENV_VAR) diff --git a/backend/app/historical_snapshots.py b/backend/app/historical_snapshots.py index b6254fa..f54c0dd 100644 --- a/backend/app/historical_snapshots.py +++ b/backend/app/historical_snapshots.py @@ -67,7 +67,6 @@ SUPPORTED_LEADERBOARD_METRICS = frozenset( PREWARM_SNAPSHOT_SERVER_KEYS = ( "comunidad-hispana-01", "comunidad-hispana-02", - "comunidad-hispana-03", ALL_SERVERS_SLUG, ) PREWARM_LEADERBOARD_METRICS = ("kills",) @@ -435,12 +434,24 @@ def _build_weekly_leaderboard_snapshot( limit: int, db_path: Path | None = None, ) -> dict[str, object]: - leaderboard_result = list_weekly_leaderboard( - limit=limit, - server_id=server_key, - metric=metric, - db_path=db_path, - ) + if get_historical_data_source_kind() == SOURCE_KIND_RCON: + from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard + + leaderboard_result = list_rcon_materialized_leaderboard( + limit=limit, + server_key=server_key, + metric=metric, + timeframe="weekly", + db_path=db_path, + now=generated_at, + ) + else: + leaderboard_result = list_weekly_leaderboard( + limit=limit, + server_id=server_key, + metric=metric, + db_path=db_path, + ) return { "server_key": server_key, "snapshot_type": SNAPSHOT_TYPE_WEEKLY_LEADERBOARD, @@ -468,12 +479,24 @@ def _build_monthly_leaderboard_snapshot( limit: int, db_path: Path | None = None, ) -> dict[str, object]: - leaderboard_result = list_monthly_leaderboard( - limit=limit, - server_id=server_key, - metric=metric, - db_path=db_path, - ) + if get_historical_data_source_kind() == SOURCE_KIND_RCON: + from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard + + leaderboard_result = list_rcon_materialized_leaderboard( + limit=limit, + server_key=server_key, + metric=metric, + timeframe="monthly", + db_path=db_path, + now=generated_at, + ) + else: + leaderboard_result = list_monthly_leaderboard( + limit=limit, + server_id=server_key, + metric=metric, + db_path=db_path, + ) return { "server_key": server_key, "snapshot_type": SNAPSHOT_TYPE_MONTHLY_LEADERBOARD, diff --git a/backend/app/rcon_historical_backfill.py b/backend/app/rcon_historical_backfill.py new file mode 100644 index 0000000..0363be2 --- /dev/null +++ b/backend/app/rcon_historical_backfill.py @@ -0,0 +1,484 @@ +"""Explicit RCON/AdminLog historical backfill command.""" + +from __future__ import annotations + +import argparse +import json +import time +from dataclasses import dataclass +from contextlib import closing +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Iterable + +from .config import ( + get_rcon_backfill_chunk_hours, + get_rcon_backfill_max_days_back, + get_rcon_backfill_sleep_seconds, + get_rcon_request_timeout_seconds, + use_postgres_rcon_storage, +) +from .historical_runner import generate_historical_snapshots +from .historical_storage import ALL_SERVERS_SLUG +from .rcon_admin_log_materialization import ( + MATCH_RESULT_SOURCE, + initialize_rcon_materialized_storage, + materialize_rcon_admin_log, +) +from .rcon_admin_log_storage import persist_rcon_admin_log_entries +from .rcon_client import HllRconConnection, RconServerTarget, build_rcon_target_key, load_rcon_targets +from .rcon_historical_leaderboards import list_rcon_materialized_leaderboard +from .sqlite_utils import connect_sqlite_readonly +from .writer_lock import backend_writer_lock, build_writer_lock_holder + +DEFAULT_ALLOWED_SERVER_KEYS = frozenset({"comunidad-hispana-01", "comunidad-hispana-02"}) +EXCLUDED_BY_DEFAULT_SERVER_KEYS = frozenset({"comunidad-hispana-03"}) + + +@dataclass(frozen=True, slots=True) +class BackfillWindow: + start: datetime + end: datetime + + @property + def lookback_seconds(self) -> int: + now = datetime.now(timezone.utc) + return max(1, int((now - self.start).total_seconds())) + + +def run_rcon_historical_backfill( + *, + servers: str | None = None, + from_value: str | None = None, + to_value: str | None = None, + ensure_recent_matches: int | None = None, + ensure_current_month: bool = False, + ensure_leaderboard_windows: bool = False, + chunk_hours: int | None = None, + sleep_seconds: float | None = None, + max_days_back: int | None = None, + dry_run: bool = False, + regenerate_snapshots: bool = False, + db_path: Path | None = None, +) -> dict[str, object]: + """Backfill AdminLog events and materialized RCON matches on explicit operator command.""" + anchor = datetime.now(timezone.utc) + resolved_chunk_hours = chunk_hours or get_rcon_backfill_chunk_hours() + resolved_sleep_seconds = ( + get_rcon_backfill_sleep_seconds() if sleep_seconds is None else sleep_seconds + ) + resolved_max_days_back = max_days_back or get_rcon_backfill_max_days_back() + selected_targets = select_backfill_targets(servers) + recent_before = count_recent_materialized_closed_matches(db_path=db_path) + monthly_before = _window_diagnostic("monthly", db_path=db_path, now=anchor) + weekly_before = _window_diagnostic("weekly", db_path=db_path, now=anchor) + requested_range = _resolve_requested_range( + anchor=anchor, + from_value=from_value, + to_value=to_value, + ensure_recent_matches=ensure_recent_matches, + ensure_current_month=ensure_current_month, + ensure_leaderboard_windows=ensure_leaderboard_windows, + max_days_back=resolved_max_days_back, + ) + windows = _build_backfill_windows( + start=requested_range["start"], + end=requested_range["end"], + chunk_hours=resolved_chunk_hours, + ) + + result: dict[str, object] = { + "status": "dry-run" if dry_run else "ok", + "dry_run": dry_run, + "servers_processed": [build_rcon_target_key(target) for target in selected_targets], + "requested_range": { + "from": _to_iso(requested_range["start"]), + "to": _to_iso(requested_range["end"]), + "reason": requested_range["reason"], + "admin_log_api": "lookback-only", + }, + "actual_windows_scanned": [], + "events_seen": 0, + "events_inserted": 0, + "duplicate_events": 0, + "matches_materialized": 0, + "matches_updated": 0, + "player_stats_materialized": 0, + "player_stats_updated": 0, + "recent_materialized_closed_match_count_before": recent_before, + "recent_materialized_closed_match_count_after": recent_before, + "monthly_selected_window_before": monthly_before, + "monthly_selected_window": monthly_before, + "weekly_selected_window_before": weekly_before, + "weekly_selected_window": weekly_before, + "snapshot_regeneration_result": None, + "errors": [], + } + + if dry_run: + result["actual_windows_scanned"] = [ + _serialize_window(window) for window in _limit_windows_for_recent_need( + windows, + ensure_recent_matches=ensure_recent_matches, + db_path=db_path, + ) + ] + return result + + try: + with backend_writer_lock( + holder=build_writer_lock_holder("app.rcon_historical_backfill") + ): + windows_to_scan = _limit_windows_for_recent_need( + windows, + ensure_recent_matches=ensure_recent_matches, + db_path=db_path, + ) + for window in windows_to_scan: + for target in selected_targets: + window_result = _scan_target_window(target, window) + result["actual_windows_scanned"].append(window_result["window"]) + result["events_seen"] = int(result["events_seen"]) + int( + window_result["events_seen"] + ) + result["events_inserted"] = int(result["events_inserted"]) + int( + window_result["events_inserted"] + ) + result["duplicate_events"] = int(result["duplicate_events"]) + int( + window_result["duplicate_events"] + ) + if window_result.get("error"): + result["errors"].append(window_result["error"]) + if resolved_sleep_seconds > 0: + time.sleep(resolved_sleep_seconds) + + materialized = materialize_rcon_admin_log(db_path=db_path) + result["matches_materialized"] = int(result["matches_materialized"]) + int( + materialized.get("matches_materialized") or 0 + ) + result["matches_updated"] = int(result["matches_updated"]) + int( + materialized.get("matches_updated") or 0 + ) + result["player_stats_materialized"] = int( + result["player_stats_materialized"] + ) + int(materialized.get("player_stats_materialized") or 0) + result["player_stats_updated"] = int(result["player_stats_updated"]) + int( + materialized.get("player_stats_updated") or 0 + ) + + if ensure_recent_matches and count_recent_materialized_closed_matches( + db_path=db_path + ) >= ensure_recent_matches: + break + + if regenerate_snapshots: + result["snapshot_regeneration_result"] = generate_historical_snapshots( + server_slug=None, + run_number=1, + ) + except Exception as exc: # noqa: BLE001 - CLI reports structured operator diagnostics + result["status"] = "error" + result["errors"].append({"error_type": type(exc).__name__, "message": str(exc)}) + + recent_after = count_recent_materialized_closed_matches(db_path=db_path) + result["recent_materialized_closed_match_count_after"] = recent_after + result["monthly_selected_window"] = _window_diagnostic("monthly", db_path=db_path, now=anchor) + result["weekly_selected_window"] = _window_diagnostic("weekly", db_path=db_path, now=anchor) + if result["errors"] and result["status"] == "ok": + result["status"] = "partial" + return result + + +def select_backfill_targets(servers: str | None) -> list[RconServerTarget]: + """Load configured RCON targets and apply safe server selection rules.""" + configured_targets = list(load_rcon_targets()) + if not configured_targets: + raise RuntimeError("No RCON targets configured in HLL_BACKEND_RCON_TARGETS.") + by_key = {build_rcon_target_key(target): target for target in configured_targets} + requested_keys = _parse_server_keys(servers) + if requested_keys: + unknown = sorted(key for key in requested_keys if key not in by_key) + if unknown: + raise ValueError(f"Unknown RCON server key(s): {', '.join(unknown)}") + return [by_key[key] for key in requested_keys] + selected = [ + target + for key, target in by_key.items() + if key in DEFAULT_ALLOWED_SERVER_KEYS and key not in EXCLUDED_BY_DEFAULT_SERVER_KEYS + ] + if not selected: + raise RuntimeError( + "No default backfill targets selected. Pass --servers with configured keys explicitly." + ) + return selected + + +def count_recent_materialized_closed_matches( + *, + server_key: str | None = None, + db_path: Path | None = None, +) -> int: + """Count materialized closed AdminLog matches available for recent-match UI.""" + resolved_path = initialize_rcon_materialized_storage(db_path=db_path) + scope_sql = "" + params: list[object] = [MATCH_RESULT_SOURCE] + if server_key and server_key != ALL_SERVERS_SLUG: + scope_sql = "AND (target_key = ? OR external_server_id = ?)" + params.extend([server_key, server_key]) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_rcon_storage import connect_postgres_compat + + connection_scope = connect_postgres_compat() + else: + connection_scope = closing(connect_sqlite_readonly(resolved_path)) + with connection_scope as connection: + row = connection.execute( + f""" + SELECT COUNT(*) AS count + FROM rcon_materialized_matches + WHERE source_basis = ? + AND ended_at IS NOT NULL + {scope_sql} + """, + params, + ).fetchone() + return int(row["count"] or 0) if row else 0 + + +def _scan_target_window(target: RconServerTarget, window: BackfillWindow) -> dict[str, object]: + target_metadata = _serialize_target(target) + serialized_window = _serialize_window(window) + try: + with HllRconConnection(timeout_seconds=get_rcon_request_timeout_seconds()) as connection: + connection.connect(host=target.host, port=target.port, password=target.password) + payload = connection.execute_json( + "GetAdminLog", + { + "LogBackTrackTime": window.lookback_seconds, + "Filters": [], + }, + ) + entries = payload.get("entries") + if not isinstance(entries, list): + entries = [] + normalized_entries = [entry for entry in entries if isinstance(entry, dict)] + delta = persist_rcon_admin_log_entries( + target=target_metadata, + entries=normalized_entries, + ) + return {"window": serialized_window, "error": None, **delta} + except Exception as exc: # noqa: BLE001 - per-window errors must not hide neighboring windows + return { + "window": serialized_window, + "events_seen": 0, + "events_inserted": 0, + "duplicate_events": 0, + "error": { + **target_metadata, + **serialized_window, + "error_type": type(exc).__name__, + "message": str(exc), + }, + } + + +def _resolve_requested_range( + *, + anchor: datetime, + from_value: str | None, + to_value: str | None, + ensure_recent_matches: int | None, + ensure_current_month: bool, + ensure_leaderboard_windows: bool, + max_days_back: int, +) -> dict[str, object]: + end = _parse_datetime_argument(to_value, default=anchor) + starts = [] + reasons = [] + if from_value: + starts.append(_parse_datetime_argument(from_value, default=anchor)) + reasons.append("explicit-range") + if ensure_current_month: + starts.append(_month_start(anchor)) + reasons.append("ensure-current-month") + if ensure_leaderboard_windows: + starts.append(_previous_month_start(_month_start(anchor))) + starts.append(_week_start(anchor) - timedelta(days=7)) + reasons.append("ensure-leaderboard-windows") + if ensure_recent_matches: + starts.append(anchor - timedelta(days=max_days_back)) + reasons.append(f"ensure-recent-matches-{ensure_recent_matches}") + if not starts: + starts.append(anchor - timedelta(days=max_days_back)) + reasons.append("default-max-days-back") + start = max(min(starts), anchor - timedelta(days=max_days_back)) + return {"start": start, "end": end, "reason": ",".join(reasons)} + + +def _build_backfill_windows( + *, + start: datetime, + end: datetime, + chunk_hours: int, +) -> list[BackfillWindow]: + windows: list[BackfillWindow] = [] + cursor = _as_utc(end) + lower = _as_utc(start) + chunk = timedelta(hours=chunk_hours) + while cursor > lower: + window_start = max(lower, cursor - chunk) + windows.append(BackfillWindow(start=window_start, end=cursor)) + cursor = window_start + return windows + + +def _limit_windows_for_recent_need( + windows: list[BackfillWindow], + *, + ensure_recent_matches: int | None, + db_path: Path | None, +) -> list[BackfillWindow]: + if not ensure_recent_matches: + return windows + if count_recent_materialized_closed_matches(db_path=db_path) >= ensure_recent_matches: + return [] + return windows + + +def _window_diagnostic( + timeframe: str, + *, + db_path: Path | None, + now: datetime, +) -> dict[str, object]: + payload = list_rcon_materialized_leaderboard( + server_key=ALL_SERVERS_SLUG, + timeframe=timeframe, + metric="kills", + limit=1, + db_path=db_path, + now=now, + ) + return { + "window_kind": payload.get("window_kind"), + "window_label": payload.get("window_label"), + "window_start": payload.get("window_start"), + "window_end": payload.get("window_end"), + "selection_reason": payload.get("selection_reason"), + "current_week_closed_matches": payload.get("current_week_closed_matches"), + "previous_week_closed_matches": payload.get("previous_week_closed_matches"), + "selected_month_start": payload.get("selected_month_start"), + "selected_month_end": payload.get("selected_month_end"), + "current_month_closed_matches": payload.get("current_month_closed_matches"), + "previous_month_closed_matches": payload.get("previous_month_closed_matches"), + "sufficient_sample": payload.get("sufficient_sample"), + } + + +def _parse_server_keys(value: str | None) -> list[str]: + return [part.strip() for part in str(value or "").split(",") if part.strip()] + + +def _parse_datetime_argument(value: str | None, *, default: datetime) -> datetime: + if value is None or str(value).strip().lower() == "now": + return default + raw = str(value).strip() + if len(raw) == 10: + raw = f"{raw}T00:00:00+00:00" + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + return _as_utc(parsed) + + +def _month_start(value: datetime) -> datetime: + point = _as_utc(value) + return point.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +def _previous_month_start(current_month_start: datetime) -> datetime: + return _month_start(current_month_start - timedelta(days=1)) + + +def _week_start(value: datetime) -> datetime: + point = _as_utc(value) + return (point - timedelta(days=point.weekday())).replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _serialize_target(target: RconServerTarget) -> dict[str, object]: + return { + "target_key": build_rcon_target_key(target), + "external_server_id": target.external_server_id, + "name": target.name, + "host": target.host, + "port": target.port, + "source_name": target.source_name, + } + + +def _serialize_window(window: BackfillWindow) -> dict[str, object]: + return { + "start": _to_iso(window.start), + "end": _to_iso(window.end), + "requested_log_backtrack_seconds": window.lookback_seconds, + } + + +def _to_iso(value: datetime) -> str: + return _as_utc(value).isoformat().replace("+00:00", "Z") + + +def _main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Backfill RCON AdminLog historical materialized matches.") + parser.add_argument("--from", dest="from_value", default=None) + parser.add_argument("--to", dest="to_value", default=None) + parser.add_argument("--servers", default=None) + parser.add_argument("--ensure-recent-matches", type=int, default=None) + parser.add_argument("--ensure-current-month", action="store_true") + parser.add_argument("--ensure-leaderboard-windows", action="store_true") + parser.add_argument("--chunk-hours", type=int, default=get_rcon_backfill_chunk_hours()) + parser.add_argument("--sleep-seconds", type=float, default=get_rcon_backfill_sleep_seconds()) + parser.add_argument("--max-days-back", type=int, default=get_rcon_backfill_max_days_back()) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--regenerate-snapshots", action="store_true") + parser.add_argument("--db-path", type=Path, default=None) + args = parser.parse_args(list(argv) if argv is not None else None) + + if args.ensure_recent_matches is not None and args.ensure_recent_matches <= 0: + raise ValueError("--ensure-recent-matches must be positive.") + if args.chunk_hours <= 0: + raise ValueError("--chunk-hours must be positive.") + if args.sleep_seconds < 0: + raise ValueError("--sleep-seconds must be zero or positive.") + if args.max_days_back <= 0: + raise ValueError("--max-days-back must be positive.") + + payload = run_rcon_historical_backfill( + servers=args.servers, + from_value=args.from_value, + to_value=args.to_value, + ensure_recent_matches=args.ensure_recent_matches, + ensure_current_month=args.ensure_current_month, + ensure_leaderboard_windows=args.ensure_leaderboard_windows, + chunk_hours=args.chunk_hours, + sleep_seconds=args.sleep_seconds, + max_days_back=args.max_days_back, + dry_run=args.dry_run, + regenerate_snapshots=args.regenerate_snapshots, + db_path=args.db_path, + ) + print(json.dumps(payload, ensure_ascii=False, indent=2, default=str)) + return 0 if payload.get("status") != "error" else 1 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/backend/app/rcon_historical_leaderboards.py b/backend/app/rcon_historical_leaderboards.py index 04231d2..4da272e 100644 --- a/backend/app/rcon_historical_leaderboards.py +++ b/backend/app/rcon_historical_leaderboards.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Literal from .config import get_storage_path, use_postgres_rcon_storage +from .config import get_historical_weekly_fallback_min_matches from .historical_storage import ALL_SERVERS_SLUG from .rcon_admin_log_materialization import ( MATCH_RESULT_SOURCE, @@ -75,6 +76,8 @@ def build_rcon_materialized_leaderboard_snapshot_payload( "current_week_closed_matches": result.get("current_week_closed_matches"), "previous_week_closed_matches": result.get("previous_week_closed_matches"), "current_month_start": result.get("current_month_start"), + "selected_month_start": result.get("selected_month_start"), + "selected_month_end": result.get("selected_month_end"), "current_month_closed_matches": result.get("current_month_closed_matches"), "previous_month_closed_matches": result.get("previous_month_closed_matches"), "sufficient_sample": result.get("sufficient_sample"), @@ -109,6 +112,7 @@ def list_rcon_materialized_leaderboard( metric: str = "kills", limit: int = 10, db_path: Path | None = None, + now: datetime | None = None, ) -> dict[str, object]: """Return a leaderboard built from materialized RCON/AdminLog player stats. @@ -120,21 +124,26 @@ def list_rcon_materialized_leaderboard( normalized_timeframe = _normalize_timeframe(timeframe) normalized_metric = _normalize_metric(metric) normalized_limit = max(1, int(limit or 10)) - window = _build_window(normalized_timeframe) - - if normalized_metric == "support": - return _empty_payload( - server_key=server_key, - timeframe=normalized_timeframe, - metric=normalized_metric, - limit=normalized_limit, - window=window, - reason="rcon-materialized-stats-do-not-include-support-score", - ) + anchor = _as_utc(now or datetime.now(timezone.utc)) resolved_path = initialize_rcon_materialized_storage(db_path=db_path) connection_scope = _connect_scope(resolved_path, db_path=db_path) with connection_scope as connection: + window = select_leaderboard_window( + connection=connection, + server_key=server_key, + timeframe=normalized_timeframe, + now=anchor, + ) + if normalized_metric == "support": + return _empty_payload( + server_key=server_key, + timeframe=normalized_timeframe, + metric=normalized_metric, + limit=normalized_limit, + window=window, + reason="rcon-materialized-stats-do-not-include-support-score", + ) rows = _fetch_leaderboard_rows( connection, server_key=server_key, @@ -143,13 +152,6 @@ def list_rcon_materialized_leaderboard( window_start=window["start"], window_end=window["end"], ) - counts = _fetch_match_counts( - connection, - server_key=server_key, - timeframe=normalized_timeframe, - window_start=window["start"], - window_end=window["end"], - ) source_range = _fetch_source_range( connection, server_key=server_key, @@ -169,20 +171,16 @@ def list_rcon_materialized_leaderboard( "window_kind": window["kind"], "window_label": window["label"], "uses_fallback": False, - "selection_reason": "rcon-materialized-current-window", - "current_week_start": _to_iso(_week_start(window["end"])), - "current_week_closed_matches": counts["current_week_closed_matches"], - "previous_week_closed_matches": counts["previous_week_closed_matches"], - "current_month_start": _to_iso(_month_start(window["end"])), - "current_month_closed_matches": counts["current_month_closed_matches"], - "previous_month_closed_matches": counts["previous_month_closed_matches"], - "sufficient_sample": { - "minimum_closed_matches": 1, - "current_week_closed_matches": counts["current_week_closed_matches"], - "current_week_has_sufficient_sample": counts["current_week_closed_matches"] >= 1, - "is_early_week": False, - "fallback_max_weekday": 2, - }, + "selection_reason": window["selection_reason"], + "current_week_start": _to_iso(window["current_week_start"]), + "current_week_closed_matches": window["current_week_closed_matches"], + "previous_week_closed_matches": window["previous_week_closed_matches"], + "current_month_start": _to_iso(window["current_month_start"]), + "selected_month_start": _to_iso(window["selected_month_start"]), + "selected_month_end": _to_iso(window["selected_month_end"]), + "current_month_closed_matches": window["current_month_closed_matches"], + "previous_month_closed_matches": window["previous_month_closed_matches"], + "sufficient_sample": window["sufficient_sample"], "source_range_start": _to_iso(source_range[0]) if source_range[0] else None, "source_range_end": _to_iso(source_range[1]) if source_range[1] else None, "items": items, @@ -280,6 +278,108 @@ def _fetch_match_counts( } +def select_leaderboard_window( + *, + connection: object, + server_key: str | None, + timeframe: str, + now: datetime | None = None, +) -> dict[str, object]: + """Select the RCON leaderboard window using weekly/monthly fallback policy.""" + anchor = _as_utc(now or datetime.now(timezone.utc)) + current_week_start = _week_start(anchor) + previous_week_start = current_week_start - timedelta(days=7) + current_month_start = _month_start(anchor) + previous_month_start = _previous_month_start(current_month_start) + minimum_week_matches = get_historical_weekly_fallback_min_matches() + current_week_count = _count_matches( + connection, + server_key=server_key, + start=current_week_start, + end=anchor, + ) + previous_week_count = _count_matches( + connection, + server_key=server_key, + start=previous_week_start, + end=current_week_start, + ) + current_month_count = _count_matches( + connection, + server_key=server_key, + start=current_month_start, + end=anchor, + ) + previous_month_count = _count_matches( + connection, + server_key=server_key, + start=previous_month_start, + end=current_month_start, + ) + + if timeframe == "monthly": + use_previous_month = anchor.day <= 7 + start = previous_month_start if use_previous_month else current_month_start + end = current_month_start if use_previous_month else anchor + return { + "start": start, + "end": end, + "days": max(1, (end.date() - start.date()).days), + "kind": "previous-month" if use_previous_month else "current-month", + "label": "Mes anterior" if use_previous_month else "Mes actual", + "selection_reason": ( + "monthly-uses-previous-month-until-day-8" + if use_previous_month + else "monthly-uses-current-month-after-day-7" + ), + "current_week_start": current_week_start, + "current_week_closed_matches": current_week_count, + "previous_week_closed_matches": previous_week_count, + "current_month_start": current_month_start, + "selected_month_start": start, + "selected_month_end": end, + "current_month_closed_matches": current_month_count, + "previous_month_closed_matches": previous_month_count, + "sufficient_sample": { + "minimum_closed_matches": 1, + "current_month_closed_matches": current_month_count, + "previous_month_closed_matches": previous_month_count, + "current_month_has_sufficient_sample": current_month_count >= 1, + "uses_previous_month_until_day": 7, + }, + } + + current_week_has_sample = current_week_count >= minimum_week_matches + start = current_week_start if current_week_has_sample else previous_week_start + end = anchor if current_week_has_sample else current_week_start + return { + "start": start, + "end": end, + "days": max(1, (end.date() - start.date()).days), + "kind": "current-week" if current_week_has_sample else "previous-week", + "label": "Semana actual" if current_week_has_sample else "Semana anterior", + "selection_reason": ( + "weekly-current-week-has-sufficient-closed-matches" + if current_week_has_sample + else "weekly-fallback-previous-week-insufficient-current-week-data" + ), + "current_week_start": current_week_start, + "current_week_closed_matches": current_week_count, + "previous_week_closed_matches": previous_week_count, + "current_month_start": current_month_start, + "selected_month_start": current_month_start, + "selected_month_end": anchor, + "current_month_closed_matches": current_month_count, + "previous_month_closed_matches": previous_month_count, + "sufficient_sample": { + "minimum_closed_matches": minimum_week_matches, + "current_week_closed_matches": current_week_count, + "current_week_has_sufficient_sample": current_week_has_sample, + "previous_week_closed_matches": previous_week_count, + }, + } + + def _fetch_source_range( connection: object, *, @@ -390,19 +490,15 @@ def _empty_payload( "window_label": window["label"], "uses_fallback": False, "selection_reason": reason, - "current_week_start": _to_iso(_week_start(window["end"])), - "current_week_closed_matches": 0, - "previous_week_closed_matches": 0, - "current_month_start": _to_iso(_month_start(window["end"])), - "current_month_closed_matches": 0, - "previous_month_closed_matches": 0, - "sufficient_sample": { - "minimum_closed_matches": 1, - "current_week_closed_matches": 0, - "current_week_has_sufficient_sample": False, - "is_early_week": False, - "fallback_max_weekday": 2, - }, + "current_week_start": _to_iso(window["current_week_start"]), + "current_week_closed_matches": window["current_week_closed_matches"], + "previous_week_closed_matches": window["previous_week_closed_matches"], + "current_month_start": _to_iso(window["current_month_start"]), + "selected_month_start": _to_iso(window["selected_month_start"]), + "selected_month_end": _to_iso(window["selected_month_end"]), + "current_month_closed_matches": window["current_month_closed_matches"], + "previous_month_closed_matches": window["previous_month_closed_matches"], + "sufficient_sample": window["sufficient_sample"], "source_range_start": None, "source_range_end": None, "items": [], @@ -430,6 +526,12 @@ def _build_window(timeframe: str) -> dict[str, object]: } +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + def _week_start(value: datetime) -> datetime: point = value.astimezone(timezone.utc) start = point - timedelta(days=point.weekday()) diff --git a/backend/tests/test_rcon_historical_backfill.py b/backend/tests/test_rcon_historical_backfill.py new file mode 100644 index 0000000..aa4663a --- /dev/null +++ b/backend/tests/test_rcon_historical_backfill.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path +from contextlib import closing +from unittest.mock import patch + +from app.rcon_admin_log_materialization import ( + MATCH_RESULT_SOURCE, + initialize_rcon_materialized_storage, +) +from app.rcon_historical_backfill import ( + count_recent_materialized_closed_matches, + run_rcon_historical_backfill, + select_backfill_targets, +) +from app.rcon_historical_leaderboards import list_rcon_materialized_leaderboard + + +TARGETS_JSON = json.dumps( + [ + { + "name": "Comunidad Hispana #01", + "slug": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + "host": "127.0.0.1", + "port": 7779, + "password": "secret", + }, + { + "name": "Comunidad Hispana #02", + "slug": "comunidad-hispana-02", + "external_server_id": "comunidad-hispana-02", + "host": "127.0.0.1", + "port": 7879, + "password": "secret", + }, + { + "name": "Comunidad Hispana #03", + "slug": "comunidad-hispana-03", + "external_server_id": "comunidad-hispana-03", + "host": "127.0.0.1", + "port": 7979, + "password": "secret", + }, + ] +) + + +class RconHistoricalBackfillTests(unittest.TestCase): + def test_monthly_window_selects_previous_month_on_days_1_to_7(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: + payload = list_rcon_materialized_leaderboard( + server_key="all-servers", + timeframe="monthly", + metric="kills", + db_path=Path(tmpdir) / "historical.sqlite3", + now=datetime(2026, 5, 7, 12, tzinfo=timezone.utc), + ) + + self.assertEqual(payload["window_kind"], "previous-month") + self.assertEqual(payload["selected_month_start"], "2026-04-01T00:00:00Z") + self.assertEqual(payload["selected_month_end"], "2026-05-01T00:00:00Z") + + def test_monthly_window_selects_current_month_on_day_8_plus(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: + payload = list_rcon_materialized_leaderboard( + server_key="all-servers", + timeframe="monthly", + metric="kills", + db_path=Path(tmpdir) / "historical.sqlite3", + now=datetime(2026, 5, 8, 12, tzinfo=timezone.utc), + ) + + self.assertEqual(payload["window_kind"], "current-month") + self.assertEqual(payload["selected_month_start"], "2026-05-01T00:00:00Z") + + def test_recent_match_ensure_stops_when_count_is_already_satisfied(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir, _patched_targets(): + db_path = Path(tmpdir) / "historical.sqlite3" + _insert_closed_matches(db_path, 100) + + payload = run_rcon_historical_backfill( + servers="comunidad-hispana-01,comunidad-hispana-02", + ensure_recent_matches=100, + dry_run=True, + db_path=db_path, + ) + + self.assertEqual(payload["recent_materialized_closed_match_count_before"], 100) + self.assertEqual(payload["actual_windows_scanned"], []) + + def test_unknown_server_is_rejected(self) -> None: + with _patched_targets(): + with self.assertRaises(ValueError): + select_backfill_targets("unknown-server") + + def test_comunidad_hispana_03_is_not_included_by_default(self) -> None: + with _patched_targets(): + selected = select_backfill_targets(None) + + self.assertEqual( + [target.external_server_id for target in selected], + ["comunidad-hispana-01", "comunidad-hispana-02"], + ) + + def test_dry_run_does_not_insert_data(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir, _patched_targets(): + db_path = Path(tmpdir) / "historical.sqlite3" + payload = run_rcon_historical_backfill( + servers="comunidad-hispana-01", + ensure_current_month=True, + dry_run=True, + db_path=db_path, + ) + + count_after = count_recent_materialized_closed_matches(db_path=db_path) + + self.assertEqual(payload["status"], "dry-run") + self.assertEqual(payload["events_inserted"], 0) + self.assertEqual(count_after, 0) + + def test_backfill_output_is_json_serializable(self) -> None: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir, _patched_targets(): + payload = run_rcon_historical_backfill( + servers="comunidad-hispana-01", + ensure_current_month=True, + dry_run=True, + db_path=Path(tmpdir) / "historical.sqlite3", + ) + + json.dumps(payload, ensure_ascii=True) + + +def _insert_closed_matches(db_path: Path, count: int) -> None: + initialize_rcon_materialized_storage(db_path=db_path) + with closing(sqlite3.connect(db_path)) as connection: + for index in range(count): + connection.execute( + """ + INSERT INTO rcon_materialized_matches ( + target_key, external_server_id, match_key, map_name, map_pretty_name, + started_at, ended_at, confidence_mode, source_basis + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "comunidad-hispana-01", + "comunidad-hispana-01", + f"match-{index}", + "stmariedumont", + "ST MARIE DU MONT", + "2026-05-01T10:00:00Z", + f"2026-05-{(index % 28) + 1:02d}T12:00:00Z", + "exact", + MATCH_RESULT_SOURCE, + ), + ) + connection.commit() + + +def _patched_targets(): + return patch.dict(os.environ, {"HLL_BACKEND_RCON_TARGETS": TARGETS_JSON}) + + +if __name__ == "__main__": + unittest.main() diff --git a/docker-compose.yml b/docker-compose.yml index 19e6c3d..557c60c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,6 +83,7 @@ services: HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-600} HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES: ${HLL_RCON_HISTORICAL_CAPTURE_MAX_RETRIES:-2} HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_RETRY_DELAY_SECONDS:-15} + HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES: ${HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES:-10} depends_on: postgres: condition: service_healthy diff --git a/docs/historical-rcon-backfill.md b/docs/historical-rcon-backfill.md new file mode 100644 index 0000000..d5b48c9 --- /dev/null +++ b/docs/historical-rcon-backfill.md @@ -0,0 +1,57 @@ +# Historical RCON AdminLog Backfill + +The RCON/AdminLog backfill is an explicit operator command. It does not run on +backend startup or on web requests. + +Run it through the advanced worker image: + +```powershell +docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-recent-matches 100 --servers comunidad-hispana-01,comunidad-hispana-02 --dry-run +``` + +Before a real manual backfill, stop the writer services to avoid waiting on the +shared writer lock: + +```powershell +docker compose --profile advanced stop historical-runner rcon-historical-worker +``` + +Restart them afterwards: + +```powershell +docker compose --profile advanced up -d historical-runner rcon-historical-worker +``` + +Examples: + +```powershell +docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-recent-matches 100 --servers comunidad-hispana-01,comunidad-hispana-02 +docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-current-month --servers comunidad-hispana-01,comunidad-hispana-02 +docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-leaderboard-windows --servers comunidad-hispana-01,comunidad-hispana-02 +docker compose run --rm rcon-historical-worker python -m app.rcon_historical_backfill --ensure-recent-matches 100 --servers comunidad-hispana-01,comunidad-hispana-02 --chunk-hours 6 --sleep-seconds 1 --max-days-back 45 --regenerate-snapshots +``` + +Direct module examples: + +```powershell +python -m app.rcon_historical_backfill --from 2026-05-01 --to now --servers comunidad-hispana-01,comunidad-hispana-02 +python -m app.rcon_historical_backfill --ensure-recent-matches 100 --servers comunidad-hispana-01,comunidad-hispana-02 +python -m app.rcon_historical_backfill --ensure-current-month --servers comunidad-hispana-01,comunidad-hispana-02 +python -m app.rcon_historical_backfill --ensure-leaderboard-windows --servers comunidad-hispana-01,comunidad-hispana-02 +``` + +Useful configuration: + +- `HLL_RCON_BACKFILL_CHUNK_HOURS`, default `6` +- `HLL_RCON_BACKFILL_SLEEP_SECONDS`, default `1` +- `HLL_RCON_BACKFILL_MAX_DAYS_BACK`, default `45` +- `HLL_BACKEND_RCON_ADMIN_LOG_LOOKBACK_MINUTES`, for normal prospective worker capture only + +The command only selects `comunidad-hispana-01` and `comunidad-hispana-02` by +default. `comunidad-hispana-03` is not included unless it is configured in +`HLL_BACKEND_RCON_TARGETS` and explicitly passed with `--servers`. + +Monthly RCON leaderboards use the previous calendar month on days 1 through 7. +From day 8 onward they use the current calendar month. Weekly RCON leaderboards +use the current week only when the current week has enough closed materialized +matches; otherwise they fall back to the previous week. From 37f5e0479b5507b85af83521f105eca8d56264a5 Mon Sep 17 00:00:00 2001 From: devRaGonSa <97627393+devRaGonSa@users.noreply.github.com> Date: Sat, 23 May 2026 14:12:22 +0200 Subject: [PATCH 39/39] fix: add observable rcon backfill operator command --- .../rcon_historical_backfill_operational.py | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 backend/app/rcon_historical_backfill_operational.py diff --git a/backend/app/rcon_historical_backfill_operational.py b/backend/app/rcon_historical_backfill_operational.py new file mode 100644 index 0000000..7017a65 --- /dev/null +++ b/backend/app/rcon_historical_backfill_operational.py @@ -0,0 +1,173 @@ +"""Observable operator backfill for RCON AdminLog. + +This command is intentionally simple and explicit. It is meant to be run after stopping +`historical-runner` and `rcon-historical-worker`, so it does not compete with the shared +writer lock loop. It prints one JSON line per step, which makes progress visible in +PowerShell and Docker logs. +""" + +from __future__ import annotations + +import argparse +import json +import time +from datetime import datetime, timezone +from typing import Iterable + +from .historical_runner import generate_historical_snapshots +from .rcon_admin_log_ingestion import ingest_rcon_admin_logs +from .rcon_admin_log_materialization import materialize_rcon_admin_log +from .rcon_historical_backfill import count_recent_materialized_closed_matches, select_backfill_targets +from .rcon_client import build_rcon_target_key + + +def run_operational_backfill( + *, + ensure_recent_matches: int, + servers: str, + max_days_back: int, + chunk_hours: int, + sleep_seconds: float, + regenerate_snapshots: bool, +) -> dict[str, object]: + started_at = datetime.now(timezone.utc) + targets = select_backfill_targets(servers) + target_keys = [build_rcon_target_key(target) for target in targets] + before = count_recent_materialized_closed_matches() + result: dict[str, object] = { + "status": "ok", + "started_at": _iso(started_at), + "admin_log_api": "lookback-only", + "exact_historical_range_supported": False, + "servers_processed": target_keys, + "ensure_recent_matches": ensure_recent_matches, + "max_days_back": max_days_back, + "chunk_hours": chunk_hours, + "recent_materialized_closed_match_count_before": before, + "recent_materialized_closed_match_count_after": before, + "events_seen": 0, + "events_inserted": 0, + "duplicate_events": 0, + "matches_materialized": 0, + "matches_updated": 0, + "windows_scanned": [], + "errors": [], + "snapshot_regeneration_result": None, + } + _log("backfill-started", result=result) + + max_minutes = max_days_back * 24 * 60 + step_minutes = chunk_hours * 60 + minutes = step_minutes + + while minutes <= max_minutes: + current_count = count_recent_materialized_closed_matches() + result["recent_materialized_closed_match_count_after"] = current_count + if current_count >= ensure_recent_matches: + result["termination_reason"] = "recent-match-target-reached" + break + + for target_key in target_keys: + _log("target-lookback-started", target_key=target_key, lookback_minutes=minutes) + try: + ingestion = ingest_rcon_admin_logs(minutes=minutes, target_key=target_key) + totals = ingestion.get("totals") if isinstance(ingestion.get("totals"), dict) else {} + materialized = materialize_rcon_admin_log() + window_summary = { + "target_key": target_key, + "lookback_minutes": minutes, + "events_seen": int(totals.get("events_seen") or 0), + "events_inserted": int(totals.get("events_inserted") or 0), + "duplicate_events": int(totals.get("duplicate_events") or 0), + "matches_materialized": int(materialized.get("matches_materialized") or 0), + "matches_updated": int(materialized.get("matches_updated") or 0), + } + result["windows_scanned"].append(window_summary) + _add(result, window_summary) + result["recent_materialized_closed_match_count_after"] = count_recent_materialized_closed_matches() + _log( + "target-lookback-finished", + **window_summary, + recent_materialized_closed_match_count_after=result["recent_materialized_closed_match_count_after"], + ) + except Exception as exc: # noqa: BLE001 - operator command must continue reporting + error = { + "target_key": target_key, + "lookback_minutes": minutes, + "error_type": type(exc).__name__, + "message": str(exc), + } + result["errors"].append(error) + _log("target-lookback-failed", error=error) + + if sleep_seconds > 0: + time.sleep(sleep_seconds) + + minutes += step_minutes + + if result["recent_materialized_closed_match_count_after"] < ensure_recent_matches: + result["status"] = "partial" + result.setdefault("termination_reason", "exhausted_available_admin_log_or_max_days_back") + + if regenerate_snapshots: + _log("snapshot-regeneration-started") + try: + result["snapshot_regeneration_result"] = generate_historical_snapshots(server_slug=None, run_number=1) + _log("snapshot-regeneration-finished", snapshot_regeneration_result=result["snapshot_regeneration_result"]) + except Exception as exc: # noqa: BLE001 + result["status"] = "partial" + error = {"phase": "snapshot-regeneration", "error_type": type(exc).__name__, "message": str(exc)} + result["errors"].append(error) + _log("snapshot-regeneration-failed", error=error) + + result["finished_at"] = _iso(datetime.now(timezone.utc)) + _log("backfill-finished", result=result) + return result + + +def _add(result: dict[str, object], window_summary: dict[str, object]) -> None: + for key in ("events_seen", "events_inserted", "duplicate_events", "matches_materialized", "matches_updated"): + result[key] = int(result.get(key) or 0) + int(window_summary.get(key) or 0) + + +def _log(event: str, **payload: object) -> None: + print(json.dumps({"event": event, **payload}, ensure_ascii=False, default=str), flush=True) + + +def _iso(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Observable RCON AdminLog backfill operator command.") + parser.add_argument("--ensure-recent-matches", type=int, default=100) + parser.add_argument("--servers", default="comunidad-hispana-01,comunidad-hispana-02") + parser.add_argument("--chunk-hours", type=int, default=3) + parser.add_argument("--sleep-seconds", type=float, default=1.0) + parser.add_argument("--max-days-back", type=int, default=45) + parser.add_argument("--regenerate-snapshots", action="store_true") + args = parser.parse_args(list(argv) if argv is not None else None) + + if args.ensure_recent_matches <= 0: + raise ValueError("--ensure-recent-matches must be positive.") + if args.chunk_hours <= 0: + raise ValueError("--chunk-hours must be positive.") + if args.sleep_seconds < 0: + raise ValueError("--sleep-seconds must be zero or positive.") + if args.max_days_back <= 0: + raise ValueError("--max-days-back must be positive.") + + payload = run_operational_backfill( + ensure_recent_matches=args.ensure_recent_matches, + servers=args.servers, + chunk_hours=args.chunk_hours, + sleep_seconds=args.sleep_seconds, + max_days_back=args.max_days_back, + regenerate_snapshots=args.regenerate_snapshots, + ) + print(json.dumps(payload, ensure_ascii=False, indent=2, default=str), flush=True) + return 0 if payload.get("status") != "error" else 1 + + +if __name__ == "__main__": + raise SystemExit(_main())