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/system-metrics.md b/ai/system-metrics.md index 6e1b458..3456941 100644 --- a/ai/system-metrics.md +++ b/ai/system-metrics.md @@ -17,3 +17,6 @@ Date | Task | Duration | Result | Notes 2026-03-19T13:59:33 | worker-cycle | 641.24 sec | success | codex-runner 2026-05-19T08:39:12 | worker-cycle | 663.71 sec | success | codex-runner 2026-05-19T17:20:44 | worker-cycle | 2240.22 sec | success | codex-runner +2026-05-21T14:43:44 | worker-cycle | 5580.9 sec | success | codex-runner +2026-05-21T15:17:18 | worker-cycle | 688.86 sec | success | codex-runner +2026-05-21T18:59:27 | worker-cycle | 527.54 sec | success | codex-runner 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/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/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/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/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/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/ai/tasks/done/TASK-145-match-detail-player-table-controls.md b/ai/tasks/done/TASK-145-match-detail-player-table-controls.md new file mode 100644 index 0000000..3d4ec82 --- /dev/null +++ b/ai/tasks/done/TASK-145-match-detail-player-table-controls.md @@ -0,0 +1,111 @@ +--- +id: TASK-145 +title: Add match detail player table controls +status: done +type: frontend +team: Frontend Senior +supporting_teams: [] +roadmap_item: foundation +priority: medium +--- + +# TASK-145 - Add match detail player table controls + +## Goal + +Keep inactive match-detail player rows low priority and add compact search, team +filtering and sorting controls to the player table. + +## Context + +The internal match detail page already exposes a compact player stat table and +expandable detail panels. Profile and lobby snapshot rows with no team and no +match activity can currently compete with real participants in that table, and +larger matches need client-side controls to find and compare players without +changing backend contracts. + +## Steps + +1. Inspect the listed files first. +2. Apply only the scoped match-detail player table change. +3. Validate the result and document relevant findings. + +## 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` + +## 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-145-match-detail-player-table-controls.md` + +## Constraints + +- Preserve the compact main columns and click-to-open player detail panels. +- Keep Aliados and Eje row treatments and badges. +- Filter team labels through the existing localized team mapping. +- Do not reintroduce expanded stats, event data or backend changes as table columns. +- Keep frontend behavior responsive and browser-loaded without new dependencies. + +## 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` +- Manual match-detail verification for default inactive ordering, stat sorting, + name search, team filters and panel close-on-filter behavior. +- Review `git diff --name-only` against task scope. + +## Outcome + +Implemented. The match-detail player table now has client-side player-name +search, localized team filtering, sort-column and direction controls above the +existing compact table. Default row ordering keeps active participants first, +orders them by kills descending, deaths ascending and name, and sends rows with +unknown team, zero combat stats and no expanded match stats to the bottom. +Numeric explicit sorts retain that inactive demotion, while name/team sorts can +compare all visible rows and inactive rows stay visually muted. + +The table continues to render row/detail pairs together after control changes. +Player-name clicks still open the existing expanded detail panel, and search or +team-filter changes close any open panel before the visible rows refresh. + +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` +- Rendered fallback verification on + `historico-partida.html?server=comunidad-hispana-02&match=comunidad-hispana-02%3A1779315955%3A1779319098%3Akharkovwarfare` + for control visibility, row detail opening, close-on-search, player-name + search, Aliados/Eje/Sin equipo filters and Jugador/Equipo/K/D/TK/K-D/KPM + sort selectors. +- Rendered default ordering on the provided match showed 18 active rows before + 44 inactive/no-team rows; visible zero-kill rows with deaths stayed in the + active block before the inactive rows. +- Desktop and mobile controls screenshots were inspected outside the repo. + +Notes: + +- Browser plugin runtime tools were not exposed by tool discovery in this + session, so rendered validation used a temporary local headless + Chrome/Selenium fallback outside the repository. +- Browser console output on the tested route only reported the existing missing + `favicon.ico` request. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/ai/tasks/done/TASK-146-match-detail-scoreboard-and-player-links.md b/ai/tasks/done/TASK-146-match-detail-scoreboard-and-player-links.md new file mode 100644 index 0000000..6320516 --- /dev/null +++ b/ai/tasks/done/TASK-146-match-detail-scoreboard-and-player-links.md @@ -0,0 +1,141 @@ +--- +id: TASK-146 +title: Recover match scoreboard and player external links +status: done +type: backend +team: Backend Senior +supporting_teams: [Frontend Senior] +roadmap_item: foundation +priority: high +--- + +# TASK-146 - Recover match scoreboard and player external links + +## Goal + +Restore the safe public scoreboard action on the internal match detail page and +expose player external profile links only when captured player identifiers make +that reliable. + +## Context + +The internal `historico-partida.html` detail payload can carry a safe +`match_url`, but users do not currently see the detail-page scoreboard action. +The same detail page has click-open player panels that can link to Steam and +SteamID64-based third-party profiles when the RCON/profile or reliable +historical data path already holds a valid SteamID64. + +## Steps + +1. Inspect the listed files and captured identifier path first. +2. Keep scoreboard actions detail-page only and add profile links only from + reliable identifiers. +3. Validate backend diagnostics, frontend syntax, integration/container checks + and rendered detail-page 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` +- `frontend/assets/js/historico-partida.js` + +## Expected Files to Modify + +- Relevant backend read-model, storage helper, diagnostics and tests for exposed + external player identifiers and profile links. +- `frontend/assets/js/historico-partida.js` +- Relevant detail-page CSS when profile links need styling. +- `ai/tasks/done/TASK-146-match-detail-scoreboard-and-player-links.md` + +## Constraints + +- Do not invent SteamIDs or use player names to build external links. +- Do not call Steam Web API or require new external credentials. +- Keep public scoreboard actions off recent match cards. +- Allow only safe Comunidad Hispana scoreboard match URLs in the detail page. +- Preserve click-open player panels and existing expanded stat sections. + +## Validation + +- `python -m compileall backend/app` +- `python -m app.storage_diagnostics` +- `node --check frontend/assets/js/historico-partida.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend` +- Inspect the provided match-detail API response with `Invoke-WebRequest`. +- Render-check the detail-page scoreboard action, player panel profile state and + recent-match-card absence of public scoreboard links. +- Review `git diff --name-only` against task scope. + +## Outcome + +Implemented. + +- The internal match detail scoreboard action now sits immediately after the + summary block and before the potentially long player table. It still uses the + existing detail-page safe scoreboard allowlist and keeps the established + `Ver en Scoreboard` regression-checked label. +- RCON detail rows derive `platform`, `steam_id_64` and Steam/Hellor/HLL Records + URLs from captured `player_id` only when it is a valid numeric 17-digit + SteamID64. Raw RCON `player_id` is still not exposed. +- Trusted public-scoreboard detail rows derive the same profile fields from + persisted `historical_players.steam_id`. +- Epic-style hex RCON IDs resolve to `platform: epic` with no Steam-only links; + other missing or unsupported IDs return `platform: unknown` with no external + link fields. +- Click-open player panels now include `Perfiles externos`. Steam-backed players + get the three external links with new-tab noopener rel attributes; missing-ID + panels show a clear unavailable state without using player names to build + URLs. +- PostgreSQL storage diagnostics now report SteamID64 availability counts for + RCON match rows, RCON profile snapshots and trusted scoreboard player rows. + +Validation passed: + +- `python -m compileall backend/app` +- `python -m app.storage_diagnostics` from `backend/` in local SQLite fallback + mode. It reports PostgreSQL external-ID diagnostics as inactive when the + PostgreSQL env is not configured locally. +- `docker compose exec backend python -m app.storage_diagnostics` after the + advanced stack build. PostgreSQL diagnostics reported: + - `rcon_match_steam_id64_rows`: `2554` + - `rcon_profile_steam_id64_rows`: `462` + - `scoreboard_player_steam_id64_rows`: `87369` +- `node --check frontend/assets/js/historico-partida.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- Targeted backend tests from `backend/`: + `python -m unittest tests.test_rcon_materialization_pipeline tests.test_scoreboard_match_links` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend` +- The provided `Invoke-WebRequest` match-detail API check returned the safe + Carentan scoreboard URL plus validated external profile links for captured + SteamID64-backed players. +- Rendered local Chrome/Selenium fallback QA verified: + - detail scoreboard action appears before the player table and opens + `https://scoreboard.comunidadhll.es:5443/games/1562094` + - Steam-backed player panel shows Steam, Hellor and HLL Records links + - a no-SteamID64 player panel shows `Perfiles externos no disponibles` + - recent `historico.html` match cards show no public scoreboard links + - mobile detail layout keeps the scoreboard action readable + +Notes: + +- Browser plugin runtime tools were not exposed by tool discovery in this + session, so rendered QA used temporary headless Chrome/Selenium checks and + screenshots outside the repository. +- Targeted backend unittest output still includes existing SQLite resource + warnings from the current test suite while tests pass. +- Browser console output on rendered checks only reported the existing missing + `favicon.ico` request. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/ai/tasks/done/TASK-147-persist-scoreboard-list-candidates.md b/ai/tasks/done/TASK-147-persist-scoreboard-list-candidates.md new file mode 100644 index 0000000..9b10103 --- /dev/null +++ b/ai/tasks/done/TASK-147-persist-scoreboard-list-candidates.md @@ -0,0 +1,200 @@ +--- +id: TASK-147 +title: Persist public scoreboard list matches as RCON candidates +status: done +type: backend +team: Backend Senior +supporting_teams: + - Arquitecto Python +roadmap_item: rcon-full-data +priority: high +--- + +# TASK-147 - Persist public scoreboard list matches as RCON candidates + +## Goal + +Make `scoreboard_candidate_backfill` persist safe rows into +`rcon_scoreboard_match_candidates` directly from `/api/get_scoreboard_maps` +list payloads, so public scoreboard URLs are available for RCON correlation +even if `/api/get_map_scoreboard` detail fetching fails, returns a different +shape, or lacks fields. + +## Background + +A real Foy match existed in the scoreboard public list: + +- scoreboard id: `1562115` +- server_number: `2` +- server slug: `comunidad-hispana-02` +- map: Foy Warfare +- start: `2026-05-20T20:54:11+00:00` +- end: `2026-05-20T22:24:11+00:00` +- score: allied `4` / axis `1` +- expected URL: `https://scoreboard.comunidadhll.es:5443/games/1562115` + +But it was not available as an RCON scoreboard candidate until manually +inserted. After manual insert and materialization, the match detail page +correctly showed `Ver en Scoreboard`. + +The backfill currently reads `/api/get_scoreboard_maps`, collects IDs, fetches +detail payloads, and calls `upsert_historical_match` from detail payloads. The +list payload already contains enough trusted data for a safe candidate: +`id`, `start`, `end`, `map.id`, `map.pretty_name`, `result.allied`, +`result.axis`, and `server_number`. + +## Constraints / DO NOT BREAK + +- Do not remove PostgreSQL migration logic. +- Do not switch back to SQLite. +- Do not break recent match cards. +- Do not show `Ver partida` in recent cards. +- Do not change detail-page scoreboard button behavior except enabling it when + `match_url` exists. +- Do not expose unsafe URLs. +- Do not generate scoreboard URLs from player names. +- Do not commit runtime DB files. + +## Allowed Changes + +- `backend/app/scoreboard_candidate_backfill.py` +- Existing backend storage/read-model helpers needed for the safe candidate + upsert path. +- Existing trusted scoreboard origin helpers needed to preserve URL safety. +- Focused backend scoreboard tests and fixtures/mocks. +- This task file when it moves through the task workflow. + +## Implementation Requirements + +1. Inspect: + - `backend/app/scoreboard_candidate_backfill.py` + - `backend/app/postgres_rcon_storage.py` + - `backend/app/postgres_display_storage.py` + - `backend/app/historical_storage.py` + - `backend/app/rcon_admin_log_materialization.py` + - `backend/app/rcon_historical_read_model.py` + - `backend/app/scoreboard_origins.py` +2. Add or reuse a safe upsert function for + `rcon_scoreboard_match_candidates`. +3. During `scoreboard_candidate_backfill`, for every list match inside the + requested window: + - build a candidate from the list payload + - validate `server_number` / `server_slug` mapping + - build `match_url` only from trusted scoreboard base URL and numeric + external match id + - persist candidate idempotently into + `rcon_scoreboard_match_candidates` +4. This candidate upsert must happen before `fetch_match_details`. +5. `fetch_match_details` must remain only for historical match/player stat + enrichment. +6. If `fetch_match_details` fails, the candidate row must still exist. +7. Add counters to the JSON report: + - `list_candidates_inserted` + - `list_candidates_updated` + - `list_candidates_skipped` + - detail candidate/detail match counters if useful +8. Keep existing historical upsert behavior. +9. Preserve trusted URL allowlist behavior. +10. Do not create candidates for untrusted origins. +11. Do not reintroduce Comunidad Hispana #03 as an active visible target. +12. Add tests covering a list payload equivalent to Foy `1562115`. +13. The test must prove: + - list payload alone persists candidate + - `match_url` is + `https://scoreboard.comunidadhll.es:5443/games/1562115` + - failure of detail fetch does not prevent candidate persistence + - operation is idempotent + +## Files to Read First + +- `AGENTS.md` +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `backend/app/scoreboard_candidate_backfill.py` +- `backend/app/postgres_rcon_storage.py` + +## Expected Files to Modify + +- Backfill/storage helper files needed for the safe list-candidate upsert. +- Focused scoreboard tests covering list-only persistence and detail failure. +- This task file after moving it through the workflow. + +Keep the task within the allowed changes. If a storage boundary requires an +additional backend file from the inspected list, document why in the task +outcome. + +## Validation + +- `python -m compileall backend/app` +- `python -m unittest discover -s tests -p "*scoreboard*"` +- `python -m app.storage_diagnostics` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend postgres` +- `docker compose exec backend python -m app.scoreboard_candidate_backfill --server comunidad-hispana-02 --from 2026-05-20T00:00:00Z --to 2026-05-21T23:59:59Z --max-pages 5 --page-size 100` +- Query `rcon_scoreboard_match_candidates` and verify external match id + `1562115` exists for `comunidad-hispana-02`. +- Invoke the detail endpoint for + `comunidad-hispana-02:1779310451:1779315851:foywarfare` and verify + `match_url` is present. +- Review `git diff --name-only` and confirm the changed files match this task. + +## Manual Verification + +1. Open: + `http://localhost:8080/historico-partida.html?server=comunidad-hispana-02&match=comunidad-hispana-02%3A1779310451%3A1779315851%3Afoywarfare` +2. Verify `Ver en Scoreboard` appears. +3. Verify it opens: + `https://scoreboard.comunidadhll.es:5443/games/1562115` +4. Verify recent cards still do not show the public scoreboard button. + +## Commit Message + +`fix: persist scoreboard list matches as rcon candidates` + +## Outcome + +Document validation, candidate upsert decisions, counters added to the JSON +report, and any follow-up task instead of widening this task. + +- Persisted list payload candidates before detail fetch through a focused + PostgreSQL upsert for `rcon_scoreboard_match_candidates`. +- Built list candidate URLs only from the trusted scoreboard origin catalog and + numeric public match IDs. List rows are accepted only when the selected + historical server slug/base URL/server number and payload `server_number` + agree with the trusted origin. +- Kept detail fetching for the existing `historical_*` match/player enrichment + path. The backfill report now exposes `list_candidates_inserted`, + `list_candidates_updated` and `list_candidates_skipped` independently from + the detail match counters. +- Added focused regression coverage for the Foy list row `1562115`; detail + fetch failure still leaves the list candidate present, and a second run + updates the same candidate key instead of creating a duplicate. +- Validation: + - `python -m compileall backend/app` + - `python -m unittest discover -s tests -p "*scoreboard*"` from `backend/` + passed with pre-existing SQLite `ResourceWarning` output in the scoreboard + regression file. + - `python -m app.storage_diagnostics` from `backend/` + - `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` + passed; the script reports no product integration tests configured for its + platform-only scope. + - `docker compose --profile advanced up -d --build backend frontend postgres` + - `docker compose exec backend python -m app.scoreboard_candidate_backfill + --server comunidad-hispana-02 --from 2026-05-20T00:00:00Z --to + 2026-05-21T23:59:59Z --max-pages 5 --page-size 100` + reported list candidate inserts/updates with no errors. + - Queried PostgreSQL and verified external match id `1562115` exists for + `comunidad-hispana-02` with URL + `https://scoreboard.comunidadhll.es:5443/games/1562115`. + - Invoked `/api/historical/matches/detail` for + `comunidad-hispana-02:1779310451:1779315851:foywarfare`; payload `found` + was true and `match_url` used the expected trusted URL. +- Scope review used `git diff --name-only`; changed implementation files stay + within this task plus this workflow task file. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split follow-up work into a new task if the scope grows. diff --git a/ai/tasks/done/TASK-148-relink-existing-rcon-matches-to-scoreboard-candidates.md b/ai/tasks/done/TASK-148-relink-existing-rcon-matches-to-scoreboard-candidates.md new file mode 100644 index 0000000..a32fec4 --- /dev/null +++ b/ai/tasks/done/TASK-148-relink-existing-rcon-matches-to-scoreboard-candidates.md @@ -0,0 +1,203 @@ +--- +id: TASK-148 +title: Relink existing RCON matches to scoreboard candidates +status: done +type: backend +team: Backend Senior +supporting_teams: + - Arquitecto Python +roadmap_item: rcon-full-data +priority: high +--- + +# TASK-148 - Relink existing RCON matches to scoreboard candidates + +## Goal + +Add a deterministic relink flow that can update or resolve scoreboard URLs for +already-materialized RCON matches after new scoreboard candidates are inserted. + +## Background + +After manually inserting the Foy `1562115` candidate, the match detail page +showed the expected scoreboard button. Future backfills may insert candidates +after RCON matches already exist, so the system must relink existing RCON +matches with null or missing `match_url` dynamically or via materialization. + +Known Foy regression target: + +- RCON match key: + `comunidad-hispana-02:1779310451:1779315851:foywarfare` +- candidate external match id: `1562115` +- expected URL: `https://scoreboard.comunidadhll.es:5443/games/1562115` + +## Constraints / DO NOT BREAK + +- Do not relax trusted scoreboard URL allowlist. +- Do not link to unsafe origins. +- Do not choose ambiguous candidates silently. +- Do not break old Carentan match: + `comunidad-hispana-02:1779178461:1779183861:carentanwarfare` +- Do not break Kharkov: + `comunidad-hispana-02:1779315955:1779319098:kharkovwarfare` +- Do not re-add public scoreboard buttons to recent match cards. +- Do not expose correlation debug text in normal UI. + +## Allowed Changes + +- Existing RCON materialization, historical read-model, and RCON storage + modules needed for deterministic candidate relinking. +- `backend/app/scoreboard_candidate_backfill.py` only when needed to trigger + or document the relink sequence. +- A focused backend relink command module or a narrowly scoped command option + on existing materialization CLI. +- Focused backend scoreboard correlation tests and fixtures/mocks. +- This task file when it moves through the task workflow. + +## Implementation Requirements + +1. Inspect current correlation logic: + - `backend/app/rcon_admin_log_materialization.py` + - `backend/app/rcon_historical_read_model.py` + - `backend/app/postgres_rcon_storage.py` + - `backend/app/scoreboard_candidate_backfill.py` +2. Add or improve a function that matches RCON materialized matches to + `rcon_scoreboard_match_candidates` using: + - same server slug / external server id + - normalized map identity + - overlapping or close time window + - score allied/axis where present + - winner where present +3. Tolerances: + - allow reasonable time drift between RCON and scoreboard, at least + +/- 15 minutes, preferably configurable or centralized + - for matches with null `started_at` / `ended_at` but `closed_at` plus + `duration_seconds`, derive a correlation window +4. When a best safe candidate is found, make `match_url` available in detail + payload. +5. Prefer deterministic scoring: + - exact map match + - close end time + - score match + - same server + - reject ambiguous ties unless one candidate is clearly better +6. Add a command or option to relink existing matches without needing new + ingestion: + - acceptable command module: + `python -m app.rcon_scoreboard_relink` + - acceptable alternative: an option inside + `app.rcon_admin_log_materialization` +7. The relink command must print JSON summary: + - `matches_scanned` + - `candidates_scanned` + - `matches_linked` + - `matches_skipped_no_candidate` + - `matches_skipped_ambiguous` + - `errors` +8. Ensure `scoreboard_candidate_backfill` can optionally trigger relink at the + end, or document the command sequence. +9. Add regression coverage for the Foy case: + - RCON match key + `comunidad-hispana-02:1779310451:1779315851:foywarfare` + - candidate external match id `1562115` + - expected `match_url` + `https://scoreboard.comunidadhll.es:5443/games/1562115` +10. Ensure the detail endpoint returns `match_url` after relink. + +## Files to Read First + +- `AGENTS.md` +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `backend/app/rcon_admin_log_materialization.py` +- `backend/app/rcon_historical_read_model.py` + +## Expected Files to Modify + +- Existing RCON correlation/materialization/storage code needed for relinking. +- One relink CLI entry point or one existing CLI option. +- Focused scoreboard correlation regression tests. +- This task file after moving it through the workflow. + +Keep correlation diagnostics out of this task except the JSON relink summary +required here. Use TASK-149 for detailed diagnostic output and docs. + +## Validation + +- `python -m compileall backend/app` +- `python -m unittest discover -s tests -p "*scoreboard*"` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `docker compose --profile advanced up -d --build backend frontend postgres` +- `docker compose exec backend python -m app.scoreboard_candidate_backfill --server comunidad-hispana-02 --from 2026-05-20T00:00:00Z --to 2026-05-21T23:59:59Z --max-pages 5 --page-size 100` +- Run the new relink command. +- Use `Invoke-WebRequest` for the Foy detail payload and verify `found` is + true and `match_url` is present. +- Use `Invoke-WebRequest` for the Carentan detail payload and verify the + existing `match_url` is still present. +- Review `git diff --name-only` and confirm the changed files match this task. + +## Manual Verification + +1. Foy detail page shows `Ver en Scoreboard`. +2. Scoreboard button opens the `1562115` public URL. +3. Recent match cards still do not show public scoreboard links. +4. No `Detalle no disponible` state appears for the validated detail page. +5. Player filters and external profile links still work. + +## Commit Message + +`fix: relink rcon matches to scoreboard candidates` + +## Outcome + +Document correlation scoring choices, time-window derivation, ambiguity +handling, relink command output, validation, and any follow-up task instead of +expanding scope. + +- Added `python -m app.rcon_scoreboard_relink` to scan already-materialized + ended RCON matches against the same trusted candidate resolution used by the + detail read model. The command reports `matches_scanned`, + `candidates_scanned`, `matches_linked`, `matches_skipped_no_candidate`, + `matches_skipped_ambiguous` and `errors` as JSON. +- Kept URL availability dynamic in the read model instead of adding a second + persisted URL column to RCON matches. The new correlation resolution summary + preserves the existing deterministic map/time/duration/score scoring path, + returns explicit low-confidence/no-candidate/ambiguous reasons, and exposes + the selected external candidate only to command callers. +- Reused the existing materialized window derivation for closed-at plus + server-time duration cases so relink and detail payloads evaluate the same + effective correlation window. +- Added a Foy regression for materialized match key + `comunidad-hispana-02:1779310451:1779315851:foywarfare`: relink reports a + safe link and detail returns + `https://scoreboard.comunidadhll.es:5443/games/1562115`. +- Validation: + - `python -m compileall backend/app` + - `python -m unittest discover -s tests -p "*scoreboard*"` from `backend/` + passed with existing SQLite `ResourceWarning` output in the scoreboard + regression file. + - `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` + passed; the script reports no product integration tests configured for its + platform-only scope. + - `docker compose --profile advanced up -d --build backend frontend postgres` + - Re-ran `app.scoreboard_candidate_backfill` for the requested May 20-21 + window. Candidate refresh succeeded (`list_candidates_updated: 11`), while + detail enrichment returned a partial status because SQLite reported + `database is locked` for detail match `1562104`. + - `docker compose exec backend python -m app.rcon_scoreboard_relink --server + comunidad-hispana-02` reported 19 scanned matches, 13 linked matches, 6 + no-candidate skips, 0 ambiguous skips and no errors. + - HTTP detail verification returned `found: true` and the expected Foy URL. + Existing Carentan detail key + `comunidad-hispana-02:1779178461:1779183861:carentanwarfare` also kept a + trusted `match_url`. +- Scope review used `git diff --name-only`; relink changes stay within the + correlation/read-model command path, focused scoreboard tests and this task + file. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split follow-up work into a new task if the scope grows. diff --git a/ai/tasks/done/TASK-149-scoreboard-correlation-diagnostics.md b/ai/tasks/done/TASK-149-scoreboard-correlation-diagnostics.md new file mode 100644 index 0000000..4cd172c --- /dev/null +++ b/ai/tasks/done/TASK-149-scoreboard-correlation-diagnostics.md @@ -0,0 +1,182 @@ +--- +id: TASK-149 +title: Add scoreboard correlation diagnostics +status: done +type: backend +team: Backend Senior +supporting_teams: + - PM +roadmap_item: rcon-full-data +priority: medium +--- + +# TASK-149 - Add scoreboard correlation diagnostics + +## Goal + +Make missing scoreboard links diagnosable without exposing debug clutter in the +normal UI. + +## Background + +When the Foy match had `match_url` null, investigation required manual +inspection of public pages, candidate tables, and historical tables. Backend +diagnostics should explain whether: + +- no candidates exist +- candidates exist but map/time/score mismatch +- candidates were ambiguous +- candidate URL was unsafe +- relink was not run + +The known Foy diagnostic target is: + +- server: `comunidad-hispana-02` +- RCON match key: + `comunidad-hispana-02:1779310451:1779315851:foywarfare` +- expected candidate when available: external match id `1562115` + +## Constraints / DO NOT BREAK + +- Do not add visible debug text to `historico-partida.html`. +- Do not add timeline/source/debug metadata back to normal UI. +- Do not change recent card layout. +- Do not require internet for unit tests. +- Use fixtures/mocks for tests. + +## Allowed Changes + +- A focused backend scoreboard correlation diagnostic command. +- Existing backend correlation/read helpers only when needed to reuse safe + candidate selection output. +- Focused backend tests using fixtures or mocks. +- Documentation explaining the missing-scoreboard-button debug sequence. +- This task file when it moves through the task workflow. + +## Implementation Requirements + +1. Add backend diagnostic capability for a given RCON match: + - preferred command: + `python -m app.scoreboard_correlation_diagnostics --server comunidad-hispana-02 --match comunidad-hispana-02:1779310451:1779315851:foywarfare` + - optional alternative: also expose it inside `storage_diagnostics`, but + never in normal frontend payloads +2. Diagnostic output must be JSON. +3. Include: + - `rcon_match_key` + - `server` + - `map` + - `started_at` / `ended_at` / `closed_at` / `duration_seconds` + - score + - candidate search window + - `candidate_count` + - top candidate summaries: + - `external_match_id` + - `started_at` + - `ended_at` + - map + - score + - `match_url` + - `correlation_score` + - `rejection_reason` if rejected + - `selected_candidate` if any + - `final_reason` +4. Add a specific diagnostic for the known Foy case. +5. Add docs explaining how to debug a missing scoreboard button: + - run `scoreboard_candidate_backfill` + - run relink + - run `scoreboard_correlation_diagnostics` + - inspect detail endpoint +6. Keep diagnostics out of normal UI. +7. Do not emit raw sensitive data. +8. Do not emit unsafe URLs. + +## Files to Read First + +- `AGENTS.md` +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- `backend/app/rcon_historical_read_model.py` +- Backend correlation/relink code delivered by TASK-148. + +## Expected Files to Modify + +- One backend diagnostic command module or the existing diagnostics module. +- Existing safe correlation/read helper files only if necessary. +- Focused backend diagnostic tests using fixtures/mocks. +- One focused documentation file for the debug workflow. +- This task file after moving it through the workflow. + +Do not add frontend debug surfaces or widen the normal detail payload for this +task. + +## Validation + +- `python -m compileall backend/app` +- `python -m app.scoreboard_correlation_diagnostics --server comunidad-hispana-02 --match comunidad-hispana-02:1779310451:1779315851:foywarfare` +- `python -m app.storage_diagnostics` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- `node --check frontend/assets/js/historico-partida.js` +- `node --check frontend/assets/js/historico.js` +- `node --check frontend/assets/js/historico-recent-live.js` +- Review `git diff --name-only` and confirm the changed files match this task. + +## Manual Verification + +1. Run diagnostics for Foy and verify it explains why `1562115` is selected or + why no candidate is selected. +2. Run diagnostics for a match without public candidate and verify it explains + no candidates found. +3. Normal frontend pages do not show diagnostics. + +## Commit Message + +`chore: add scoreboard correlation diagnostics` + +## Outcome + +Document diagnostic fields, known Foy output behavior, documentation added, +validation performed, and any follow-up task instead of expanding normal UI +payloads. + +- Added `python -m app.scoreboard_correlation_diagnostics` for one + materialized RCON match. JSON output includes match key, server, map, + timestamps, duration, score, candidate search window, safe top candidates, + selected candidate and `final_reason`. +- Candidate summaries expose trusted public `match_url` values only. Untrusted + URLs are omitted and receive `unsafe-url`; non-scoring candidates report + `map-or-window-mismatch` where applicable. +- Kept diagnostics out of normal detail and frontend payloads. The command + reuses the materialized correlation window and safe candidate scoring that + relink/detail use. +- Added `docs/scoreboard-correlation-debugging.md` with the missing-button + sequence: candidate backfill, relink scan, diagnostics command and detail + endpoint check. +- Extended focused Foy coverage so diagnostics select external match id + `1562115` with the expected Foy candidate summary. +- Validation: + - `python -m compileall backend/app` + - `python -m unittest discover -s tests -p "*scoreboard*"` from `backend/` + passed with existing SQLite `ResourceWarning` output in the scoreboard + regression file. + - `python -m app.scoreboard_correlation_diagnostics --server + comunidad-hispana-02 --match + comunidad-hispana-02:1779310451:1779315851:foywarfare` from `backend/` + returned `final_reason: linked` and selected candidate `1562115`. + - `python -m app.storage_diagnostics` + - `node --check frontend/assets/js/historico-partida.js` + - `node --check frontend/assets/js/historico.js` + - `node --check frontend/assets/js/historico-recent-live.js` + - `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` + exited successfully after reporting its platform checks. Its later local + route probe printed a SQLite `database disk image is malformed` traceback + from `historical_storage`; this task did not alter or repair runtime DB + files. +- Scope review used `git diff --name-only`; diagnostics changes stay in the + correlation command/test/doc path and this task file. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split follow-up work into a new task if the scope grows. diff --git a/ai/tasks/done/TASK-150-server-card-history-and-current-match-entrypoints.md b/ai/tasks/done/TASK-150-server-card-history-and-current-match-entrypoints.md new file mode 100644 index 0000000..f89246b --- /dev/null +++ b/ai/tasks/done/TASK-150-server-card-history-and-current-match-entrypoints.md @@ -0,0 +1,190 @@ +--- +id: TASK-150 +title: Server card history and current match entrypoints +status: done +type: frontend +team: Frontend Senior +supporting_teams: [] +roadmap_item: rcon-full-data +priority: high +--- + +# TASK-150 - Server card history and current match entrypoints + +## Goal + +Add server-specific navigation buttons to the home page and make the internal +historical page accept an initial server filter through the URL. + +## Background + +The home page currently renders live server cards in +`frontend/assets/js/main.js`. Each server card currently has a single +"Historico" action built through `renderServerAction(server)`. + +We need clearer per-server actions for the active Comunidad Hispana servers: + +- Open the public current scoreboard. +- Open our internal historical page already filtered by that server. +- Open our future internal live/current match page already filtered by that + server. + +Important URL correction: + +The current public scoreboard URLs are the base scoreboard URLs, without +`/games`: + +- `comunidad-hispana-01` -> `https://scoreboard.comunidadhll.es` +- `comunidad-hispana-02` -> `https://scoreboard.comunidadhll.es:5443` + +The `/games` URLs are only for the external public historical scoreboard, if +needed later: + +- `comunidad-hispana-01` -> `https://scoreboard.comunidadhll.es/games` +- `comunidad-hispana-02` -> `https://scoreboard.comunidadhll.es:5443/games` + +## Constraints / DO NOT BREAK + +- Do not change backend storage. +- Do not change RCON ingestion. +- Do not change scoreboard correlation logic. +- Do not depend on server #03. +- Do not expose arbitrary URLs received from API payloads. +- Do not remove existing server card live status rendering. +- Do not implement the full live match page in this task. +- Keep the change frontend-focused and minimal. +- Preserve existing responsive layout. + +## Allowed Changes + +- `frontend/assets/js/main.js` +- `frontend/assets/css/main.css` or equivalent home CSS file +- historical page JS/CSS/HTML files needed to support `?server=` +- optionally add a minimal placeholder `partida-actual.html` if needed to + avoid a broken link +- docs only if needed + +## Implementation Requirements + +1. Replace the current single "Historico" server-card action with a small + action group. +2. For each active supported server, render these actions: + - "Scoreboard público" + - "Nuestro histórico" + - "Partida actual" +3. "Scoreboard público" must open the current public scoreboard base URL: + - `comunidad-hispana-01` -> `https://scoreboard.comunidadhll.es` + - `comunidad-hispana-02` -> `https://scoreboard.comunidadhll.es:5443` + It must NOT append `/games`. +4. "Nuestro histórico" must open the internal historical page with a server + query parameter: + - `historico.html?server=comunidad-hispana-01` + - `historico.html?server=comunidad-hispana-02` +5. "Partida actual" must open: + - `partida-actual.html?server=comunidad-hispana-01` + - `partida-actual.html?server=comunidad-hispana-02` +6. Add trusted frontend mappings for supported active servers only: + - `comunidad-hispana-01` + - `comunidad-hispana-02` + Do not add server #03. +7. The server identifier should be resolved from the server payload using + `external_server_id` or another already existing stable server slug. If the + server cannot be resolved to a trusted known server, do not render + public/current-match actions for it. +8. Update the historical page initialization so that: + - `?server=comunidad-hispana-01` selects Comunidad Hispana #01 instead of + defaulting to all servers. + - `?server=comunidad-hispana-02` selects Comunidad Hispana #02 instead of + defaulting to all servers. + - unknown or missing server query values fall back to the current default + behavior. +9. If `partida-actual.html` does not exist yet, create a minimal placeholder + page that: + - reads `?server=` + - displays "Partida actual" + - displays the selected server slug/name + - explains that the live detail view will be implemented in the next task + - includes a safe link back to the home page and historical page + Do not implement live data polling yet. +10. Ensure all generated URLs are built from trusted constants, not directly + from API-provided arbitrary values. + +## Files to Read First + +- `AGENTS.md` +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/frontend-senior.md` +- `frontend/assets/js/main.js` +- historical page files used for the existing server filter flow + +## Expected Files to Modify + +- `frontend/assets/js/main.js` +- home CSS only if the new action group needs layout styling +- historical page files needed for `?server=` initialization +- `partida-actual.html` only if a placeholder is needed + +## Validation + +- Run `node --check` on every modified frontend JS file. +- Review `git diff --name-only` and confirm the changed files match this task. + +## Manual Verification + +1. Open the home page. +2. Verify Comunidad Hispana #01 card shows: + - Scoreboard público + - Nuestro histórico + - Partida actual +3. Verify Comunidad Hispana #02 card shows: + - Scoreboard público + - Nuestro histórico + - Partida actual +4. Click "Scoreboard público" for Comunidad Hispana #01 and verify it opens: + `https://scoreboard.comunidadhll.es` +5. Click "Scoreboard público" for Comunidad Hispana #02 and verify it opens: + `https://scoreboard.comunidadhll.es:5443` +6. Click "Nuestro histórico" for Comunidad Hispana #01 and verify the + historical page loads with Comunidad Hispana #01 selected. +7. Click "Nuestro histórico" for Comunidad Hispana #02 and verify the + historical page loads with Comunidad Hispana #02 selected. +8. Click "Partida actual" for both servers and verify the page opens with the + correct server query parameter. +9. Verify no server #03 action is introduced. + +## Commit Message + +`feat: add current match server entrypoints` + +## Expected Outcome + +The home page exposes clear per-server navigation actions, and the internal +historical page can open directly filtered by server. + +## Outcome + +- Replaced the home server-card history URL path with a trusted frontend action + catalog for active servers `comunidad-hispana-01` and + `comunidad-hispana-02` only. Payload-provided `community_history_url` + values and the previous server #03 fallback are no longer used for those + card actions. +- Added the three per-server entrypoints from trusted constants: public + scoreboard base URL, internal historical filter URL and internal current + match URL. +- Confirmed `frontend/assets/js/historico.js` already normalizes the supported + `?server=` values into the active selector and falls back to `all-servers` + for unknown or missing values, so no historical page code change was needed. +- Added a minimal `partida-actual.html` placeholder with a trusted server label + map and safe internal links for TASK-151 to upgrade. +- Validation: `node --check frontend/assets/js/main.js`. +- Scope review: `git diff --name-only` and `git status --short` were reviewed + for the task move plus frontend files. Browser click verification was not + completed because the in-app browser JavaScript control tool was not + available after tool discovery in this session. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if the scope grows. diff --git a/ai/tasks/done/TASK-151-current-match-page-base.md b/ai/tasks/done/TASK-151-current-match-page-base.md new file mode 100644 index 0000000..cc129fc --- /dev/null +++ b/ai/tasks/done/TASK-151-current-match-page-base.md @@ -0,0 +1,190 @@ +--- +id: TASK-151 +title: Current match page base +status: done +type: frontend +team: Frontend Senior +supporting_teams: + - Backend Senior +roadmap_item: rcon-full-data +priority: high +--- + +# TASK-151 - Current match page base + +## Goal + +Create the first functional internal "Partida actual" page for active +Comunidad Hispana servers. + +## Background + +The home page will link to +`partida-actual.html?server=`. We need the first real version of +our internal current-match page. + +This page should be visually aligned with the existing historical match detail +page, but it represents a live match instead of a closed historical match. + +Important public scoreboard URLs: + +- `comunidad-hispana-01` -> `https://scoreboard.comunidadhll.es` +- `comunidad-hispana-02` -> `https://scoreboard.comunidadhll.es:5443` + +These are base URLs for the current public scoreboard and must not include +`/games`. + +## Constraints / DO NOT BREAK + +- Do not break the existing historical page. +- Do not break the existing match detail page. +- Do not change scoreboard correlation logic. +- Do not implement the live kill feed in this task unless there is already a + safe endpoint that can be reused trivially. +- Do not fabricate closed-match data. +- Do not show final winner/duration/closed_at for a live match. +- Do not depend on server #03. +- Do not expose untrusted public scoreboard URLs. +- Keep polling moderate and safe. + +## Allowed Changes + +- `partida-actual.html` +- new frontend JS/CSS for current match page +- backend API endpoint only if needed +- backend read-model code only if needed for current live state +- tests where practical + +## Implementation Requirements + +1. Create or upgrade `partida-actual.html` as a real live-current-match page. +2. The page must read `?server=` from the URL. +3. Supported server values: + - `comunidad-hispana-01` + - `comunidad-hispana-02` +4. Unknown server values must show a safe error/empty state and must not build + external URLs from the unknown value. +5. Add trusted current public scoreboard links: + - `comunidad-hispana-01` -> `https://scoreboard.comunidadhll.es` + - `comunidad-hispana-02` -> `https://scoreboard.comunidadhll.es:5443` +6. Add a "Ver scoreboard público" button using the trusted base URL for that + server. +7. The page should display a live-match header with: + - server name + - live/online status + - current map + - game mode if available + - `started_at` if available + - current players / max players if available + - last updated/captured timestamp +8. The page should display a scoreboard panel: + - allied score if available + - axis score if available + - neutral state if scores are unavailable +9. Do not display: + - final duration + - final winner + - `closed_at` + - any copy implying that the match has finished +10. Add a placeholder section for future live kill feed: + - title: "Feed de combate" + - text explaining that live kill events will appear here when enabled + - no fake kill rows +11. Add a player table section if current player stats are already available + from existing APIs. If they are not available, show a clean placeholder: + - "Las estadísticas en vivo aparecerán cuando haya datos suficientes." +12. Add frontend polling with safe in-flight protection: + - default interval: 30 seconds + - avoid overlapping requests + - show stale/error states clearly +13. If no backend endpoint exists for current match state, add a minimal + endpoint that derives the current server state from existing live/RCON + snapshot data. Keep it read-only. +14. The endpoint response should be stable and minimal: + - server slug/name + - status + - map + - `game_mode` + - `started_at` + - `allied_score` + - `axis_score` + - players + - `max_players` + - `captured_at`/`updated_at` + - `public_scoreboard_url` from trusted mapping + +## Files to Read First + +- `AGENTS.md` +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/frontend-senior.md` +- current historical match detail frontend files +- live server backend/read-model files used by the existing server cards + +## Expected Files to Modify + +- `partida-actual.html` +- new current-match frontend JS/CSS files as needed +- minimal live-state backend endpoint/read-model files only if existing APIs + cannot supply the page state +- focused tests where practical + +## Validation + +- `python -m compileall backend/app` +- Run backend tests relevant to live server/read model if available. +- Run `node --check` on modified/new frontend JS files. +- Review `git diff --name-only` and confirm the changed files match this task. + +## Manual Verification + +1. Open `partida-actual.html?server=comunidad-hispana-01`. +2. Open `partida-actual.html?server=comunidad-hispana-02`. +3. Verify the public scoreboard button opens: + - `https://scoreboard.comunidadhll.es` + - `https://scoreboard.comunidadhll.es:5443` +4. Verify `/games` is not used for the current public scoreboard button. +5. Verify no final/closed-match fields are shown. +6. Verify unknown server query values do not create unsafe links. + +## Commit Message + +`feat: add current match page base` + +## Expected Outcome + +A safe first version of the internal current-match page exists and can display +live server/match state without pretending the match is closed. + +## Outcome + +- Upgraded `partida-actual.html` into the first internal live match page and + kept it aligned with the existing historical shell/styles. +- Added frontend polling every 30 seconds with an in-flight guard. The page + rejects unknown `?server=` values before building any external link, and the + public scoreboard button is populated only from the trusted backend + projection. +- Added read-only `GET /api/current-match?server=`. It supports only active + trusted scoreboard origins and projects the existing live server snapshot + fields into the current-match shape. The current live snapshot persistence + exposes status, map, population and capture time; score, game mode and match + start fields remain `null`/unavailable when the snapshot source does not + provide them. +- Kept the combat feed and live player statistics as honest empty placeholders + for the follow-up tasks rather than fabricating closed-match or kill data. +- Validation: `python -m compileall backend/app`; + `node --check frontend/assets/js/partida-actual.js`; + `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`; + narrow inline route guard check for missing/unknown current-match server + values; narrow inline payload projection check using a controlled live + snapshot document. +- Scope review: `git diff --name-only` and `git status --short` were reviewed. + No focused product route test file exists in `backend/tests` for this API + bootstrap layer yet. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if the scope grows. diff --git a/ai/tasks/done/TASK-152-current-match-live-kill-feed.md b/ai/tasks/done/TASK-152-current-match-live-kill-feed.md new file mode 100644 index 0000000..ba5bcff --- /dev/null +++ b/ai/tasks/done/TASK-152-current-match-live-kill-feed.md @@ -0,0 +1,174 @@ +--- +id: TASK-152 +title: Current match live kill feed +status: done +type: backend +team: Backend Senior +supporting_teams: + - Frontend Senior +roadmap_item: rcon-full-data +priority: high +--- + +# TASK-152 - Current match live kill feed + +## Goal + +Expose recent current-match kill events and render them as a live visual feed +on `partida-actual.html`. + +## Background + +The current-match page will exist at +`partida-actual.html?server=`. The backend already stores/parses +RCON AdminLog data and materializes kill/player stats for historical matches. + +Existing materialization logic uses AdminLog kill payload fields such as: + +- `killer_id` +- `killer_name` +- `killer_team` +- `victim_id` +- `victim_name` +- `victim_team` +- `weapon` + +We now want a live/current-match kill feed similar to a FPS kill feed: killer, +weapon, victim, teamkill distinction, timestamp. + +## Constraints / DO NOT BREAK + +- Do not query RCON directly from the frontend. +- Do not expose raw AdminLog lines. +- Do not expose admin-only/sensitive fields. +- Do not break existing historical materialization. +- Do not break existing match detail player stats. +- Do not depend on server #03. +- Do not fabricate kill events. +- Do not duplicate kill rows on repeated polling. +- Keep polling safe. + +## Allowed Changes + +- backend endpoint for current match kill feed +- backend read-model/helper for current/open match event window +- frontend current-match JS/CSS +- tests for event normalization/filtering where practical + +## Implementation Requirements + +1. Add a backend endpoint for recent current-match kill events by server. +2. Supported servers: + - `comunidad-hispana-01` + - `comunidad-hispana-02` +3. Unknown server values must return a safe 400/404 style response and must + not query arbitrary targets. +4. The endpoint should return normalized event rows: + - `event_id` + - `event_timestamp` or `server_time` + - `killer_name` + - `killer_team` + - `victim_name` + - `victim_team` + - `weapon` + - `is_teamkill` + - confidence/source if needed +5. The endpoint should only return events belonging to the current/open match + if that can be determined. +6. If the current/open match window cannot be determined reliably, return a + safe recent window and include a clear confidence marker, for example: + - scope: `"recent-admin-log-window"` + - confidence: `"partial"` +7. Do not return raw AdminLog text. +8. Add frontend rendering for a kill feed: + - newest events visible at top or bottom consistently + - killer name + - weapon label/icon placeholder + - victim name + - timestamp + - teamkill visual distinction +9. Prevent duplicate rendering: + - track `event_id` values already rendered + - update existing rows only if needed +10. Poll every 15-30 seconds. +11. Add an empty state: + - "Todavía no se han detectado bajas en esta partida." +12. Add an error/stale state: + - "No se pudo actualizar el feed de combate." +13. Do not show fake/sample kill events in production UI. + +## Files to Read First + +- `AGENTS.md` +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `ai/orchestrator/backend-senior.md` +- current-match page files created by TASK-151 +- AdminLog materialization/read-model files that already normalize kill data + +## Expected Files to Modify + +- backend current-match kill feed endpoint/read-model files +- current-match frontend JS/CSS files +- focused tests for event normalization/filtering where practical + +## Validation + +- `python -m compileall backend/app` +- Run backend tests related to AdminLog/materialization/current-match feed. +- Run `node --check` on current-match frontend JS. +- Review `git diff --name-only` and confirm the changed files match this task. + +## Manual Verification + +1. Open `partida-actual.html?server=comunidad-hispana-01`. +2. Open `partida-actual.html?server=comunidad-hispana-02`. +3. Verify kill feed renders only normalized events. +4. Verify repeated polling does not duplicate rows. +5. Verify teamkills are visually distinguishable. +6. Verify raw AdminLog lines are never displayed. + +## Commit Message + +`feat: add current match live kill feed` + +## Expected Outcome + +The current-match page can show a safe, normalized, non-duplicated live kill +feed based on RCON/AdminLog data. + +## Outcome + +- Added read-only `GET /api/current-match/kills?server=&limit=` for the active + trusted Comunidad Hispana servers. Unsupported servers fail before any + AdminLog query is built. +- Added a safe AdminLog kill feed read that emits normalized fields only: + generated `event_id`, event time/server time, killer/victim names and teams, + weapon, and computed `is_teamkill`. It does not return raw AdminLog text, + player ids or admin-only payload fields. +- Feed scope prefers `open-admin-log-match-window` with + `confidence: "admin-log-boundary"` when the latest AdminLog boundary for the + server is an unmatched `match_start`. Otherwise it returns + `recent-admin-log-window` with `confidence: "partial"` as the explicit + fallback. +- Extended `partida-actual.js` to poll the feed with the existing 30-second + in-flight-protected current-match refresh cycle, dedupe rows by `event_id`, + keep newest events ordered by server/event time, render a TK badge for + teamkills, and show empty/error states without fake rows. +- Added a focused AdminLog storage test for open-window filtering and teamkill + normalization. The environment does not have `pytest` installed (`pytest` + and `python -m pytest` both failed), so the same storage normalization path + was verified with a narrow inline Python scenario. +- Validation: `python -m compileall backend/app`; + `node --check frontend/assets/js/partida-actual.js`; + `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`; + narrow inline route guard check for missing, unknown and invalid-limit feed + requests; inline storage normalization check excluding pre-window kills and + confirming no raw message field leaks. +- Scope review: `git diff --name-only` and `git status --short` were reviewed. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if the scope grows. 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/ai/tasks/done/TASK-154-current-match-killfeed-overlay-layout.md b/ai/tasks/done/TASK-154-current-match-killfeed-overlay-layout.md new file mode 100644 index 0000000..cf50026 --- /dev/null +++ b/ai/tasks/done/TASK-154-current-match-killfeed-overlay-layout.md @@ -0,0 +1,198 @@ +# TASK-154 - Current match killfeed overlay layout + +Status: in-progress + +## Goal + +Redesign the current-match kill feed frontend so it behaves like a compact live killfeed overlay. + +## Background + +The current-match page already renders a kill feed using: + +- `GET /api/current-match/kills?server=...` + +The feed currently appears as a vertical list of large historical-style cards. This is not the desired UX. + +## Desired UX + +The kill feed should look like a compact live FPS-style overlay: + +- A rectangular live panel. +- Events rendered as compact rows/chips. +- Each event should show: + - killer name + - weapon icon or weapon label fallback + - victim name +- Events should be arranged in three visual columns inside the panel. +- New events should appear progressively. +- Older events should move left and eventually disappear. +- The feed should feel like a real-time combat screen, not a historical list. + +The user explicitly wants: + +- "una especie de pantalla en tiempo real" +- "simplemente se muestre el texto, el que mata, el arma y alguien mata" +- "iconos del arma que se utiliza" +- "una pequeña pantalla rectangular" +- "se irá poniendo de arriba abajo en tres columnas" +- "se irá desplazando hacia la izquierda e irán desapareciendo las más antiguas" + +## Scope + +Replace the current large-card kill feed with a compact live overlay on the current-match page. + +Allowed changes: + +- `frontend/assets/js/partida-actual.js` +- `frontend/partida-actual.html` if needed +- `frontend/assets/css/historico.css` or the relevant CSS used by `partida-actual.html` +- `frontend/assets/css/styles.css` only if the current-match page depends on it +- `frontend/assets/img/weapons/*` if local weapon icons/placeholders are added +- backend only if a small weapon normalization field is needed, but prefer frontend-side mapping first +- focused tests or node validation + +## Constraints - DO NOT BREAK + +- Do not break `/api/current-match/kills`. +- Do not expose raw AdminLog lines. +- Do not fabricate kill events. +- Do not show stale kills as live kills. +- Do not break the current-match scoreboard/header. +- Do not break historical match detail pages. +- Do not query RCON directly from the frontend. +- Do not depend on server #03. +- Do not require external/CDN assets at runtime. +- If weapon icons are added, they must be local/static assets or generated lightweight inline/SVG placeholders. +- Keep the UI responsive. + +## Files to inspect first + +Read: + +- `frontend/partida-actual.html` +- `frontend/assets/js/partida-actual.js` +- the CSS currently used by `frontend/partida-actual.html` +- focused current-match kill feed tests or validation scripts if present + +Inspect the current kill feed rendering, `event_id` handling, scope copy, and current polling behavior before changing code. + +## Implementation requirements + +### 1. Compact live panel + +Replace the current large-card feed rendering with a compact live killfeed panel. + +The panel must be visually rectangular and compact. It should look like a live combat overlay, not a list of historical cards. + +### 2. Layout + +- Use a three-column visual layout on desktop. +- Events should fill vertically within a column, then continue through the next visual position. +- Newer events should be visually prioritized. +- Older events should shift left and disappear once the maximum number of visible events is exceeded. +- On narrow/mobile widths, fall back to one or two columns without overflow. + +### 3. Event content + +Each kill event must show: + +- killer name +- weapon icon or weapon label +- victim name +- optional timestamp only if it does not make the UI noisy +- teamkill indicator if `is_teamkill` is true + +### 4. Weapon icons + +Add a safe mapping for common weapons currently seen in AdminLog examples: + +- `M1 GARAND` +- `MP40` +- `M1A1 THOMPSON` +- `UNKNOWN` + +Prefer local SVG/icon placeholders if real weapon assets are not available. + +- Do not hotlink external images. +- Unknown weapons must show a generic weapon icon/label fallback. +- The icon mapping should be easy to extend later. + +### 5. Motion and transition + +- Use CSS transitions/animations only if they are subtle. +- Avoid layout jumps. +- Respect users with reduced motion if possible. +- The feed should not flicker every poll. + +### 6. Deduplication + +- Preserve existing `event_id` deduplication. +- Re-rendering must not duplicate rows. +- Repeated polling must keep already visible events stable. + +### 7. Maximum visible events + +- Limit visible events to a reasonable number, for example 12 or 15. +- Older events should be dropped from the visual panel. +- Do not render an infinitely growing list. + +### 8. Copy + +Use these messages: + +- If there are no events: "Todavía no se han detectado bajas en esta partida." +- If scope is open match window: "Bajas detectadas en la partida actual." +- If scope is `recent-admin-log-window`: "Cobertura parcial desde AdminLog reciente." +- If stale/no current events: "Sin bajas recientes asociadas a la partida actual." + +### 9. Accessibility + +- Keep `aria-live` polite or equivalent. +- Event text must remain readable even if icons fail. + +## Validation + +Run: + +- `node --check frontend/assets/js/partida-actual.js` +- any existing frontend validation if available + +## Manual verification checklist + +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-01`. +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-02`. +- Verify kill feed appears as a compact rectangular live overlay. +- Verify events render as killer -> weapon/icon -> victim. +- Verify it uses three columns on desktop. +- Verify old events disappear instead of growing endlessly. +- Verify repeated polling does not duplicate events. +- Verify teamkills are visually distinguishable. +- Verify unknown weapons use a clean fallback. +- Verify no raw AdminLog line is shown. +- Verify no external image URLs are required. + +## Expected outcome + +The current-match kill feed looks and behaves like a compact live FPS-style killfeed overlay with local/fallback weapon icon support. + +## AI Platform lifecycle + +After implementation and validation: + +- Move this task according to the lifecycle defined in `AGENTS.md`. +- Do not mark unrelated tasks as done automatically. + +## Outcome + +- Replaced the historical-style kill cards with a capped 15-event rectangular overlay in `partida-actual`. +- Kept `event_id` deduplication and avoided poll flicker by only re-rendering when the visible event-id set changes. +- Rendered older visible events first so they occupy the left side of the three-column desktop panel while newer events remain visually prioritized on the right. +- Added local text-glyph weapon placeholders for `M1 GARAND`, `MP40`, `M1A1 THOMPSON` and unknown/other weapons without external assets. +- Validation run: + - `node --check frontend/assets/js/partida-actual.js` + - `scripts/run-historical-ui-regression-tests.ps1` + - `git diff --check` + - `git diff --name-only` +- Scope review: changed product files are `frontend/assets/js/partida-actual.js` and `frontend/assets/css/historico.css`. +- Manual/rendered Browser QA remains to be repeated when the Browser automation entry point is exposed; the local frontend and backend current-match endpoints were reachable during validation. diff --git a/ai/tasks/done/TASK-155-current-match-live-player-stats.md b/ai/tasks/done/TASK-155-current-match-live-player-stats.md new file mode 100644 index 0000000..f904903 --- /dev/null +++ b/ai/tasks/done/TASK-155-current-match-live-player-stats.md @@ -0,0 +1,245 @@ +# TASK-155 - Current match live player stats + +Status: in-progress + +## Goal + +Investigate and implement live player/statistics rendering for the current-match page using the safest available data source. + +## Background + +The current-match page has an "Estadísticas en vivo" section, but it currently remains a placeholder: + +> "Las estadísticas en vivo aparecerán cuando haya datos suficientes." + +The user reports that players are not being shown. + +Known context: + +- `/api/current-match` exposes players and team player counts from RCON `getSession`, but those counts may be marked as `rcon-session-unverified`. +- Public scoreboard can show player population even when RCON `getSession` reports 0. +- AdminLog kill events already expose player names through normalized kill feed rows. +- Historical materialization already derives player stats from AdminLog data for completed matches. +- The current-match page should eventually show useful live player rows, but only if data is reliable enough. + +## Scope + +Investigate the available live player data sources and wire the current-match page to a safe current player statistics payload. + +Allowed changes: + +- `backend/app/routes.py` +- `backend/app/payloads.py` +- `backend/app/rcon_admin_log_storage.py` +- backend read-model/helper files if needed +- `frontend/assets/js/partida-actual.js` +- `frontend/partida-actual.html` +- `frontend/assets/css/historico.css` or relevant CSS +- focused backend tests + +## Constraints - DO NOT BREAK + +- Do not fabricate player stats. +- Do not show an empty/misleading table as if it were real data. +- Do not show RCON `getSession` `playerCount=0` as reliable if quality is `rcon-session-unverified`. +- Do not expose raw AdminLog lines. +- Do not expose admin-only/sensitive data. +- Do not break historical materialization. +- Do not break historical match detail stats. +- Do not query RCON directly from the frontend. +- Do not depend on server #03. +- Do not scrape the public scoreboard unless explicitly chosen and documented as a safe server-side fallback. +- Keep current public scoreboard URLs trusted and without `/games`. + +## Files to inspect first + +Read: + +- `frontend/partida-actual.html` +- `frontend/assets/js/partida-actual.js` +- `backend/app/routes.py` +- `backend/app/payloads.py` +- `backend/app/rcon_admin_log_storage.py` +- focused tests and historical player stat materialization helpers related to current-match or AdminLog data + +Inspect the existing RCON session payload, current kill feed freshness logic, open AdminLog match window support, and historical player stat aggregation before choosing a source. + +## Required investigation before implementation + +Perform RCA first and determine what live player data sources currently exist: + +- RCON session payload +- any RCON player-list command/wrapper already available in the codebase +- AdminLog kill/death events +- existing historical player stat materialization +- current open AdminLog match window if available + +Document the chosen source in the done task notes: + +- what was available +- what was not available +- whether stats are complete, partial, or event-derived + +## Implementation requirements + +### 1. Backend endpoint + +Add or extend a backend endpoint for current-match player stats. + +Preferred route: + +- `GET /api/current-match/players?server=...` + +### 2. Supported servers + +Support: + +- `comunidad-hispana-01` +- `comunidad-hispana-02` + +### 3. Unknown server values + +- Return a safe 400/404 style response. +- Do not query arbitrary targets. + +### 4. Data model + +The endpoint should return: + +- `server_slug` +- `server_name` +- `scope` +- `confidence` +- `source` +- `captured_at` or `updated_at` +- `items`: array of player rows + +### 5. Player row fields + +Include only fields that can be supported reliably: + +- `player_name` +- `team` if known +- `kills` if known +- `deaths` if known +- `teamkills` if known +- `deaths_by_teamkill` if known +- `favorite_weapon` or `most_used_weapon` if known +- `last_seen_at` if known +- `confidence`/`source` if needed + +### 6. AdminLog-derived data + +If using AdminLog-derived data: + +- Clearly mark scope/confidence as partial/event-derived. +- Only include players observed in recent/current match event windows. +- Do not imply this is the full server roster. +- Use a freshness threshold consistent with kill feed freshness logic. +- If no current/open event window exists, return empty items with a clear scope. + +### 7. RCON player-list data + +If a real RCON player-list command exists: + +- Prefer it for live roster. +- Combine it with AdminLog-derived kills/deaths only when safe. +- Mark fields not supported by RCON as null. +- Do not block the endpoint indefinitely on RCON timeouts. + +### 8. Frontend rendering + +Replace the static "Estadísticas en vivo" placeholder with dynamic rendering. + +If items exist: + +- Show a compact table or cards with player stats. +- Sort by kills descending, then deaths ascending, then name. +- Indicate if stats are partial. + +If items are empty, show: + +- "Todavía no hay estadísticas fiables de jugadores para esta partida." + +If data is partial, show: + +- "Estadísticas parciales derivadas de eventos recientes." + +### 9. Frontend polling + +- Poll player stats with the current-match page refresh cycle or a safe separate interval. +- Avoid overlapping requests. +- Do not flicker table rows on every poll. +- Avoid duplicate player rows. + +### 10. Tests + +Add focused backend tests for: + +- unsupported server rejection +- empty current stats response +- AdminLog-derived player aggregation if used +- stale event filtering +- no raw AdminLog exposure +- stable sorting +- partial confidence metadata + +## Validation + +Run: + +- `python -m compileall backend/app` +- focused backend tests if available +- `node --check frontend/assets/js/partida-actual.js` + +## Manual verification checklist + +- Call `Invoke-RestMethod "http://localhost:8000/api/current-match/players?server=comunidad-hispana-01" | ConvertTo-Json -Depth 20`. +- Call `Invoke-RestMethod "http://localhost:8000/api/current-match/players?server=comunidad-hispana-02" | ConvertTo-Json -Depth 20`. +- Verify unsupported server values are rejected safely. +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-01`. +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-02`. +- Verify the "Estadísticas en vivo" section no longer remains permanently empty when current reliable/partial player data exists. +- Verify the UI clearly says when stats are partial or unavailable. +- Verify no fake players are shown. +- Verify no raw AdminLog lines are shown. +- Verify existing historical detail stats still work. + +## Expected outcome + +The current-match page can show live or partial player statistics when supported by reliable/current data, and otherwise shows a clear honest empty state. + +## AI Platform lifecycle + +After implementation and validation: + +- Move this task according to the lifecycle defined in `AGENTS.md`. +- Do not mark unrelated tasks as done automatically. + +## Outcome + +### RCA and source choice + +- `RCON getSession` remains available for the current-match header but its current player count is explicitly marked `rcon-session-unverified`. +- No dedicated RCON player-list wrapper or route exists in the current `rcon_client` implementation. +- AdminLog kill events already expose safe normalized player names, teams, weapons and current-match window/freshness handling through the kill-feed read model. +- The historical AdminLog materializer derives player combat stats from the same kind of kill events for closed matches. +- Chosen source: current/fresh normalized AdminLog kill rows. The new current-match player projection is intentionally `event-derived-partial`, not a complete live roster. + +### Change summary + +- Added `GET /api/current-match/players?server=...` for trusted active current-match servers only. +- Added a safe AdminLog-derived player aggregator with kills, deaths, teamkills, teamkill deaths, team, last-seen timestamp and favorite weapon when observed. +- Kept stale fallback filtering aligned with the current kill feed and kept raw AdminLog messages out of the payload. +- Replaced the static current-match player placeholder with a polled partial-stat table and an honest empty state. + +### Validation + +- Ran `python -m compileall backend/app`. +- Ran `node --check frontend/assets/js/partida-actual.js`. +- Attempted `python -m pytest backend/tests/test_current_match_payload.py backend/tests/test_rcon_admin_log_storage.py`; the active Python environment does not have `pytest` installed. +- Ran a direct Python smoke harness for trusted/unsupported route resolution, AdminLog aggregation, teamkill handling, stale filtering and raw-log exclusion. +- Ran `scripts/run-historical-ui-regression-tests.ps1`. +- Ran `git diff --check` and `git diff --name-only`. +- Task-specific changed product files are `backend/app/routes.py`, `backend/app/payloads.py`, `backend/app/rcon_admin_log_storage.py`, `backend/tests/test_current_match_payload.py` and `frontend/assets/js/partida-actual.js`. The current worktree also contains `frontend/assets/css/historico.css` from completed `TASK-154`. +- Rendered Browser QA remains to be repeated when the Browser automation entry point is exposed in-session. diff --git a/ai/tasks/done/TASK-156-current-match-realtime-killfeed-and-home-buttons-layout.md b/ai/tasks/done/TASK-156-current-match-realtime-killfeed-and-home-buttons-layout.md new file mode 100644 index 0000000..7007878 --- /dev/null +++ b/ai/tasks/done/TASK-156-current-match-realtime-killfeed-and-home-buttons-layout.md @@ -0,0 +1,203 @@ +# TASK-156 - Current match realtime killfeed and home buttons layout + +Status: done + +## Goal + +Refine the current-match kill feed into a real-time FPS-style panel and make the home server card buttons sit at the bottom-right aligned with the map card, without changing unrelated layout. + +## Background + +The current-match page already has a compact kill feed overlay implemented in `frontend/assets/js/partida-actual.js` and related CSS. + +The current visual direction is closer to the goal, but the desired behavior is now more specific: + +- The feed should feel like a real-time FPS killfeed screen. +- It should not look like a static list of historical events. +- Each event should show only: + - killer + - weapon icon or compact weapon label + - victim +- The feed should behave like a small rectangular live panel. +- Events should fill from top to bottom in three visual columns. +- As new events arrive, old events should visually shift left and eventually disappear. +- The visible feed must be capped and must not grow indefinitely. +- Repeated polling must not duplicate events or cause visual flicker. + +There is also a small home page layout request: + +- In the home server cards, only the "Historico" and "Partida actual" buttons should remain. +- Those buttons should sit lower, at the bottom-right, aligned with the map card height. +- Do not change anything else in the home layout. + +Current user-observed issues: + +- The kill feed is still too much like a list rather than a real-time screen. +- The home server card buttons are still too high and leave unused empty space in the bottom-right corner. + +## Files to Read First + +- `AGENTS.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` +- `frontend/partida-actual.html` +- `frontend/assets/js/partida-actual.js` +- `frontend/assets/js/main.js` + +Inspect the current-match kill feed behavior, current polling/deduplication state, relevant current-match CSS, and the home server card action markup before changing code. + +## Expected Files to Modify + +Allowed changes: + +- `frontend/assets/js/partida-actual.js` +- `frontend/partida-actual.html` if needed +- `frontend/assets/css/historico.css` or relevant current-match CSS +- `frontend/assets/css/styles.css` or home CSS for the server card button alignment +- `frontend/assets/js/main.js` only if the button markup still needs cleanup +- local weapon icon/fallback assets if necessary +- focused frontend validation + +## Constraints - DO NOT BREAK + +- Do not break `/api/current-match/kills`. +- Do not expose raw AdminLog lines. +- Do not fabricate kill events. +- Do not show stale kills as live kills. +- Do not query RCON directly from the frontend. +- Do not break the current-match scoreboard/header. +- Do not break the historical page. +- Do not break historical match detail pages. +- Do not depend on server #03. +- Do not add external/CDN image dependencies. +- Do not reintroduce the home card "Scoreboard publico" button. +- Do not change the home server card content except button placement/layout if not strictly needed. +- Keep current public scoreboard URLs trusted and without `/games`. + +## Implementation Requirements + +### 1. Current-match kill feed layout + +- Replace any remaining card/list feel with a compact rectangular "live feed screen". +- The feed should visually fit in a bounded panel. +- It should not expand vertically without limit. +- It should show a maximum number of events, for example 12 or 15. + +### 2. Event rendering + +Each event must show only: + +- killer name +- weapon icon or compact weapon badge +- victim name + +Avoid noisy timestamp display in the main row unless it is subtle or useful. + +### 3. Three-column live flow + +- Desktop layout must visually use three columns. +- Events should fill top-to-bottom and then across columns in a predictable way. +- Newer events should be visually prioritized. +- Older events should move/disappear as the capped list updates. +- On tablet/mobile, fall back safely to two columns or one column. + +### 4. Real-time behavior + +- Preserve `event_id` deduplication. +- Do not duplicate rows on polling. +- Avoid full-panel flicker every poll. +- Keep already-rendered events stable when no new events arrive. +- Add/remove events cleanly when the cap is exceeded. + +### 5. Weapon display + +- Use local/fallback icons or compact glyph badges. +- Include mappings for currently seen examples: + - `M1 GARAND` + - `MP40` + - `M1A1 THOMPSON` + - `UNKNOWN` +- Unknown weapons should show a generic fallback, not broken image. +- Do not use external URLs. + +### 6. Teamkill + +- Teamkills must remain distinguishable but compact. +- Do not let teamkill badges dominate the layout. + +### 7. Feed copy + +- If there are no current events: "Todavia no se han detectado bajas en esta partida." +- If events are current/open-window: "Bajas detectadas en la partida actual." +- If events are fresh but partial: "Cobertura parcial desde AdminLog reciente." +- If stale/no current events: "Sin bajas recientes asociadas a la partida actual." + +### 8. Home server card layout + +- Ensure only two per-server buttons are visible: + - "Historico" + - "Partida actual" +- Remove/hide "Scoreboard publico" from the home server card. +- Move the two buttons down to the lower-right of the card, aligned visually with the map card at the bottom-left. +- Keep the map card compact and do not enlarge it. +- Do not change the rest of the home content/layout. +- Preserve responsive behavior. + +### 9. Validation + +- Run `node --check frontend/assets/js/partida-actual.js`. +- Run `node --check frontend/assets/js/main.js` if modified. +- Run `git diff --check`. +- Rebuild frontend if needed. + +## Manual Verification Steps + +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-01`. +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-02`. +- Verify the kill feed looks like a compact rectangular live overlay. +- Verify each event row shows: + - killer + - weapon icon/badge + - victim +- Verify the feed uses three columns on desktop. +- Verify older events disappear instead of growing endlessly. +- Verify repeated polling does not duplicate events. +- Verify the feed does not flicker on every refresh. +- Verify unknown weapons use a fallback. +- Verify no raw AdminLog line is displayed. +- Open the home page: `http://localhost:8080/`. +- Verify each server card shows only: + - Historico + - Partida actual +- Verify those buttons sit at the bottom-right, visually aligned with the map card. +- Verify no other home page layout area changes unexpectedly. + +## Expected Outcome + +The current-match kill feed behaves like a compact real-time combat overlay, and the home server card action buttons are cleanly aligned bottom-right with only "Historico" and "Partida actual" visible. + +## Outcome + +- Kept the existing `event_id`-based killfeed refresh path and changed only its + rendering surface: the capped 15-event panel now reads as a bounded combat + screen with three desktop columns, while smaller breakpoints remain bounded. +- Removed the home server-card public-scoreboard action from the hydrated markup + and left the remaining `Historico` / `Partida actual` actions aligned by the + existing status-column layout. +- Validated with: + - `node --check frontend/assets/js/partida-actual.js` + - `node --check frontend/assets/js/main.js` + - `git diff --check` + - `docker compose up -d --build frontend` + - live endpoint checks for `/api/current-match/kills` on Comunidad Hispana + `#01` and `#02` + - rendered Chrome headless screenshots of the home page, current-match `#01` + desktop view and current-match `#02` mobile empty-state view +- The in-app Browser automation runtime was not callable after tool discovery, + so rendered validation used local headless Chrome against the task URLs. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/ai/tasks/done/TASK-157-current-match-player-team-colors.md b/ai/tasks/done/TASK-157-current-match-player-team-colors.md new file mode 100644 index 0000000..6bf3b5b --- /dev/null +++ b/ai/tasks/done/TASK-157-current-match-player-team-colors.md @@ -0,0 +1,197 @@ +# TASK-157 - Current match player team colors + +Status: done + +## Goal + +Render current-match player/stat rows with team-aware colors/styles consistent with the historical match detail page. + +## Background + +The current-match page now has a live player/statistics section backed by: + +- `GET /api/current-match/players?server=...` + +The current implementation is AdminLog-derived and partial/event-derived because: + +- RCON `getSession` player counts are unverified. +- No current RCON player-list wrapper exists in the codebase. +- AdminLog kill events can identify players involved in recent/current events. + +The user now wants players to be shown like in the historical detailed match page: + +- Players should be visually distinguishable depending on team. +- Team coloring should match or be consistent with the historical match detail page. +- The current live stats section should not remain visually generic. + +## Files to Read First + +- `AGENTS.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` +- `frontend/assets/js/partida-actual.js` +- `frontend/partida-actual.html` +- the historical match detail frontend code and CSS that already color player teams + +Inspect the current `/api/current-match/players` payload, the live player rendering, and existing historical detail team classes before choosing frontend or backend changes. + +## Expected Files to Modify + +Allowed changes: + +- `frontend/assets/js/partida-actual.js` +- `frontend/partida-actual.html` if needed +- `frontend/assets/css/historico.css` or relevant current-match CSS +- `backend/app/payloads.py` only if team fields are not currently projected clearly +- `backend/app/rcon_admin_log_storage.py` only if normalized team values need cleanup +- focused tests if backend normalization changes + +## Constraints - DO NOT BREAK + +- Do not fabricate team values. +- Do not imply the player list is a complete live roster if it is only AdminLog-derived. +- Do not expose raw AdminLog lines. +- Do not expose admin-only/sensitive fields. +- Do not break historical match detail player stats. +- Do not break historical materialization. +- Do not query RCON directly from the frontend. +- Do not depend on server #03. +- Do not show misleading team colors if team is unknown. +- Do not show fake players. + +## Implementation Requirements + +### 1. RCA first + +- Inspect the current `/api/current-match/players` response. +- Determine whether each player row includes team or enough information to infer team safely. +- Compare CSS/classes used by the historical match detail page for team coloring. + +### 2. Backend data contract + +If player rows already include reliable team values: + +- Preserve the existing endpoint and document available team fields. + +If team values exist in storage but are not exposed: + +- Add normalized team fields to each player row, for example: + - `team` + - `team_label` + - `team_side` +- Use only values supported by AdminLog/current data. + +If team is unknown: + +- Return null/unknown. +- Do not guess. + +### 3. Frontend rendering + +- Update the current-match player stats section so rows/cards are styled by team: + - Allied/US/Soviet/Commonwealth/etc. side should use the same or consistent allied styling. + - Axis/Germany side should use the same or consistent axis styling. + - Unknown team should use neutral styling. +- Reuse historical detail team classes if possible. +- If historical detail uses existing CSS modifiers, prefer them over creating conflicting styles. + +### 4. Visual requirements + +- Player name should be readable. +- Team color should be visible but not overwhelming. +- Team badge/label may be shown if useful. +- Partial/event-derived confidence must remain visible. +- The section should not look like a final historical table unless data is complete. + +### 5. Sorting + +- Preserve current sorting if implemented: + - kills descending + - deaths ascending + - name +- If not implemented, add stable sorting consistent with current endpoint. + +### 6. Empty state + +If no player data exists: + +- Show: "Todavia no hay estadisticas fiables de jugadores para esta partida." + +### 7. Partial state + +If stats are AdminLog-derived: + +- Show: "Estadisticas parciales derivadas de eventos recientes." +- Do not imply this is the complete roster. + +### 8. Tests + +If backend changes are made, add/update focused tests for: + +- team fields are projected when available +- unknown team remains unknown/neutral +- no raw AdminLog exposure +- unsupported server rejection still works +- partial/event-derived metadata is preserved + +### 9. Validation + +- Run `python -m compileall backend/app` if backend changed. +- Run focused backend tests if changed. +- Run `node --check frontend/assets/js/partida-actual.js`. +- Run `git diff --check`. + +## Manual Verification Steps + +- Call `Invoke-RestMethod "http://localhost:8000/api/current-match/players?server=comunidad-hispana-01" | ConvertTo-Json -Depth 20`. +- Call `Invoke-RestMethod "http://localhost:8000/api/current-match/players?server=comunidad-hispana-02" | ConvertTo-Json -Depth 20`. +- Verify player rows include team/side data when available or unknown/null when not. +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-01`. +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-02`. +- Verify live player/stat rows are colored by team when team is known. +- Verify unknown-team players use neutral styling. +- Verify the section clearly indicates partial/event-derived stats. +- Verify no fake complete roster is implied. +- Verify historical match detail player colors still work. + +## Expected Outcome + +The current-match player/stat section uses team-aware colors consistent with the historical detail page while remaining honest about partial/event-derived live data. + +## Outcome + +- RCA found no backend contract gap: a live + `/api/current-match/players?server=comunidad-hispana-01` response observed + before the validation rebuild already exposed AdminLog-derived `team` values + such as `Allies` and `Axis`; unknown values remain handled by the existing + frontend neutral path. +- Reused the historical detail player row and team badge CSS modifiers from + `historico-scoreboard-detail.css` in the current-match player table instead + of adding a parallel style system. +- Included `team` in the visible player-table signature so a future team update + can rerender an already-visible row without changing its stats. +- Validated with: + - `node --check frontend/assets/js/partida-actual.js` + - `git diff --check` + - `docker compose up -d --build frontend` + - live `/api/current-match/players` checks on Comunidad Hispana `#01` and + `#02` + - rendered Chrome headless current-match validation for the available player + empty state after the backend restart + - served-script check confirming the historical row and badge modifiers are + present in `frontend/assets/js/partida-actual.js` +- The final rendered validation window had no current player rows after the + backend rebuild, so known-team colors were validated from the observed live + payload contract plus the reused historical class mapping rather than from a + live colored-row screenshot. +- Repository-level follow-up validation ran + `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`. + It returned exit code `0`, but its output also printed a historical SQLite + `database disk image is malformed` traceback after the pass banner; that + historical storage issue is outside this frontend task scope. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/ai/tasks/done/TASK-158-current-match-realtime-transport.md b/ai/tasks/done/TASK-158-current-match-realtime-transport.md new file mode 100644 index 0000000..62f9336 --- /dev/null +++ b/ai/tasks/done/TASK-158-current-match-realtime-transport.md @@ -0,0 +1,193 @@ +--- +id: TASK-158 +title: Current match realtime transport +status: done +type: backend +team: Backend Senior +supporting_teams: [Frontend Senior] +roadmap_item: foundation +priority: high +--- + +# TASK-158 - Current match realtime transport + +## Goal + +Implement a real-time or near-real-time update strategy for the current-match page, prioritizing the kill feed and then current match metadata/player stats. + +## Background + +The current-match page currently refreshes through polling. In `frontend/assets/js/partida-actual.js`, the current interval is: + +`CURRENT_MATCH_POLL_INTERVAL_MS = 30 * 1000` + +The user explicitly rejected this behavior: + +- "He dicho que se actualice en vivo y no cada 20 segundos" + +The page should behave much closer to real time, especially for the kill feed. A 20/30-second refresh is too slow for a live combat screen. + +Current endpoints: + +- `GET /api/current-match?server=...` +- `GET /api/current-match/kills?server=...` +- `GET /api/current-match/players?server=...` + +## Files to Read First + +- `AGENTS.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` +- `frontend/assets/js/partida-actual.js` +- `backend/app/routes.py` +- `backend/app/rcon_admin_log_storage.py` + +Inspect the current frontend refresh flow, current-match route payloads, kill feed read model, trusted server validation, and recent AdminLog materialization cadence before changing code. + +## Expected Files to Modify + +Allowed changes: + +- `backend/app/routes.py` +- `backend/app/payloads.py` +- `backend/app/rcon_admin_log_storage.py` +- backend helper/read-model files if needed +- `frontend/assets/js/partida-actual.js` +- `frontend/partida-actual.html` if needed +- focused tests + +## Constraints - DO NOT BREAK + +- Do not query RCON directly from the frontend. +- Do not expose raw AdminLog lines. +- Do not fabricate events. +- Do not show stale kills as live kills. +- Do not depend on server #03. +- Do not break existing REST endpoints. +- Do not break historical pages or historical match detail pages. +- Keep trusted server validation. +- Keep current public scoreboard URLs without `/games`. +- Avoid overloading the backend/RCON/AdminLog pipeline. +- Commit and push after implementation. + +## Implementation Requirements + +### 1. RCA first + +- Document current update flow in TASK done notes. +- Confirm which parts are safe to update at high frequency: + - kill feed + - player stats + - match metadata/scoreboard +- Identify whether the backend has access to sufficiently fresh AdminLog data without calling RCON per browser poll. + +### 2. Preferred transport + +Evaluate and implement the safest available option: + +- Server-Sent Events (SSE) endpoint for live kill events, OR +- short polling for killfeed every 1-2 seconds with ETag/since cursor/last_event_id, OR +- another minimal transport that gives near-real-time updates without duplicating events. + +Prefer SSE if it is simple and safe in the current backend stack. + +If SSE is not appropriate, implement short polling with cursor semantics. + +### 3. Kill feed endpoint behavior + +Add support for incremental fetching: + +- `since_event_id` or since timestamp/server_time +- `limit` +- server slug +- trusted server validation + +The endpoint must return only new events where possible. + +### 4. Frontend behavior + +- Remove dependency on a 20/30-second interval for kill feed updates. +- Kill feed should update in near-real-time. +- Avoid overlapping requests. +- Preserve `event_id` deduplication. +- Do not re-render the whole panel if no new events arrived. +- Keep a capped event buffer. + +### 5. Match metadata and player stats + +- These do not need to update every second. +- Keep a slower safe refresh for scoreboard/player stats if needed, for example 10-30 seconds. +- The kill feed must be faster than metadata refresh. + +### 6. Failure/reconnect + +- If SSE is used, implement reconnect behavior. +- If short polling is used, handle transient errors without breaking the page. +- Show a small stale/error state only when needed. + +### 7. Backend load + +- Do not query expensive RCON calls per browser every second. +- Prefer reading already persisted/recent AdminLog materialized data. +- If a live AdminLog ingestion cadence is insufficient, document it instead of hiding the limitation. + +## Validation + +Run: + +- `python -m compileall backend/app` +- focused backend tests if added/updated +- `node --check frontend/assets/js/partida-actual.js` +- `git diff --check` + +Before completing the task also confirm that `git diff --name-only` matches the expected scope. + +## Manual Verification Steps + +- Rebuild backend and frontend. +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-01`. +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-02`. +- During an active match, verify new kill events appear without waiting 20/30 seconds. +- Verify repeated updates do not duplicate events. +- Verify no raw AdminLog lines appear. +- Verify stale events are not displayed as live. +- Verify metadata/scoreboard remains stable and does not flicker. +- Verify backend logs do not show excessive RCON/API pressure. + +## Expected Outcome + +The current-match kill feed updates in near-real-time, without depending on the existing 30-second page refresh cycle. + +## Outcome + +RCA: + +- The current page used one `30s` interval in `frontend/assets/js/partida-actual.js` for current-match metadata, player stats and the kill feed together. +- The kill feed read path is already safe for higher-frequency reads because it queries normalized persisted `rcon_admin_log_events` rows through `/api/current-match/kills`; it does not query RCON from the browser or expose raw AdminLog lines. +- Current match metadata can still call live RCON through the existing payload path, and player stats aggregate the kill feed read model, so those surfaces remain on the slower refresh path. + +Transport decision: + +- Implemented incremental short polling for the kill feed instead of SSE. The backend is still the standard-library JSON route stack, while the existing persisted read model can cheaply support a cursor. +- `/api/current-match/kills` now accepts `since_event_id` plus the existing `server` and `limit` inputs. Trusted server validation remains in `routes.py`. +- The frontend polls kill-feed rows every `1500ms`, sends the latest event id cursor after the first load, deduplicates by `event_id`, caps the in-memory visible buffer and avoids overlapping kill-feed requests. Metadata and player stats keep the existing `30s` refresh cadence. + +Validation performed: + +- `python -m compileall backend/app` +- `node --check frontend/assets/js/partida-actual.js` +- `git diff --check` +- Ran a direct Python smoke check for `list_current_match_kill_feed(..., since_event_id=...)` with temporary AdminLog rows after the focused pytest command could not run. +- Attempted focused backend tests with `python -m pytest backend/tests/test_current_match_payload.py backend/tests/test_rcon_admin_log_storage.py`; this environment failed with `No module named pytest`. +- Reviewed `git diff --name-only`; task-owned code changes are limited to the allowed current-match backend/frontend files plus the focused storage test and this task transition. The worktree already contained frontend and prior task changes from `TASK-156`/`TASK-157`. + +Load and freshness limit: + +- Browser polling now reads persisted AdminLog rows rather than issuing per-browser RCON calls. +- New kill visibility is still bounded by how quickly `app.rcon_historical_worker` or manual AdminLog ingestion persists fresh AdminLog rows. The current documented worker example is a `120s` loop, so operations may need a follow-up cadence decision if that persisted source is not fresh enough for the desired live screen. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/ai/tasks/done/TASK-159-current-match-feed-rollback-and-weapon-icons.md b/ai/tasks/done/TASK-159-current-match-feed-rollback-and-weapon-icons.md new file mode 100644 index 0000000..5b006a6 --- /dev/null +++ b/ai/tasks/done/TASK-159-current-match-feed-rollback-and-weapon-icons.md @@ -0,0 +1,188 @@ +--- +id: TASK-159 +title: Current match feed rollback and weapon icons +status: done +type: frontend +team: Frontend Senior +supporting_teams: [Disenador grafico, Experto en interfaz] +roadmap_item: foundation +priority: high +--- + +# TASK-159 - Current match feed rollback and weapon icons + +## Goal + +Rollback/refactor the current killfeed visual layout to the previous accepted live feed style and integrate local weapon icons where available. + +## Background + +The latest compact three-column killfeed implementation is not accepted visually. The user wants to return to the previous live feed screen direction and then improve it. + +User feedback: + +- "Quiero volver a la pantalla de feed de la partida en vivo de antes, esta no me convence" +- The desired feed should still be a live combat screen, but not the current overly table/grid-like layout. +- Weapon icons were manually added to the repository for testing and should be reused. + +Current issue: + +The current killfeed visually feels too much like a table/grid and not enough like a readable live combat feed. The user wants the previous feed visual style back, then use weapon icons. + +## Files to Read First + +- `AGENTS.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` +- `frontend/partida-actual.html` +- `frontend/assets/js/partida-actual.js` +- relevant current-match CSS and `frontend/assets/img/weapons/` + +Inspect git history/diff around the feed changes before choosing whether to restore prior markup or intentionally recreate the previous direction. + +## Expected Files to Modify + +Allowed changes: + +- `frontend/assets/js/partida-actual.js` +- `frontend/partida-actual.html` if needed +- `frontend/assets/css/historico.css` or relevant CSS +- `frontend/assets/img/weapons/*` only if renaming/organizing existing local icons is necessary +- no backend changes unless weapon normalization is strictly needed + +## Constraints - DO NOT BREAK + +- Do not break `/api/current-match/kills`. +- Do not expose raw AdminLog lines. +- Do not fabricate kill events. +- Do not show stale kills as live kills. +- Do not query RCON directly from the frontend. +- Do not break the current-match scoreboard/header. +- Do not break the player stats section. +- Do not break historical pages. +- Do not depend on server #03. +- Do not hotlink external weapon images. +- Use local weapon icon assets only. +- Unknown weapons must have a clean fallback. +- Commit and push after implementation. + +## Implementation Requirements + +### 1. RCA first + +- Identify the previous feed layout/style before TASK-156/TASK-157 changes using git history/diff. +- Identify what parts of the previous style the user likely preferred: + - card readability + - vertical event readability + - less table/grid feel +- Document this in TASK done notes. + +### 2. Rollback/refactor visual style + +- Restore the previous feed screen style or recreate it intentionally. +- It should be readable and live-oriented. +- It should not look like the current table-like three-column grid. +- Keep the feed bounded and capped; do not allow infinite growth. +- Keep deduplication. + +### 3. Event content + +Each event should show: + +- killer +- weapon icon +- victim + +Optional timestamp may be shown only if visually subtle. + +### 4. Weapon icon discovery + +- Inspect `frontend/assets/img/weapons` or any newly added weapon icon folder. +- Reuse local icons the user added. +- Do not create external dependencies. +- If filenames are inconsistent, add a safe mapping instead of renaming unless renaming is clearly cleaner. + +### 5. Weapon icon mapping + +Add mapping for at least: + +- `M1 GARAND` +- `MP40` +- `M1A1 THOMPSON` +- `GEWEHR 43` +- `MG42` +- `UNKNOWN` +- generic fallback + +Also support simple normalization: + +- uppercase/lowercase differences +- spaces/hyphens +- common AdminLog weapon strings + +### 6. Visual behavior + +- Events should appear as compact readable live-feed entries. +- Avoid the current big empty table-like columns. +- Avoid full panel flicker. +- Limit visible entries, for example 10-15. +- Older events should disappear when the cap is exceeded. + +### 7. Empty states + +- If no current events: "Todavía no se han detectado bajas en esta partida." +- If stale/no current events: "Sin bajas recientes asociadas a la partida actual." + +## Validation + +Run: + +- `node --check frontend/assets/js/partida-actual.js` +- `git diff --check` +- rebuild frontend + +Before completing the task also confirm that `git diff --name-only` matches the expected scope. + +## Manual Verification Steps + +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-01`. +- Open `http://localhost:8080/partida-actual.html?server=comunidad-hispana-02`. +- Verify the feed no longer looks like the current table/grid. +- Verify it resembles the previous live feed screen direction. +- Verify each event shows killer, weapon icon, victim. +- Verify unknown weapons use fallback. +- Verify local weapon icons load correctly. +- Verify repeated updates do not duplicate rows. +- Verify stale events are not shown as live. +- Verify the player stats section still works. + +## Expected Outcome + +The current-match feed returns to the previous preferred live-feed visual direction while using the newly added local weapon icons. + +## Outcome + +Visual RCA: + +- Git history before the compact feed change showed a vertical `historical-match-card` list with one readable event per row/card. The rejected version introduced a fixed multi-column killfeed surface that reads like a grid/table. +- The feed was refactored back to a vertical card-like list while keeping the current live buffer, deduplication and no-flicker signature guard. + +Weapon icon decision: + +- Reused local assets already present under `frontend/assets/img/weapons/`. +- `partida-actual.js` maps normalized AdminLog weapon names for `M1 GARAND`, `MP40`, `M1A1 THOMPSON`, `GEWEHR 43`, `MG42` and aliases to local PNGs. +- Unknown/unmapped weapons keep their label for tooltip/accessibility and render a compact local fallback marker instead of hotlinking assets. + +Validation performed: + +- `node --check frontend/assets/js/partida-actual.js` +- `git diff --check` +- `docker compose build frontend` +- Reviewed the history diff for the prior accepted feed direction and inspected local weapon asset filenames. +- Rendered Browser verification was attempted through the required Browser workflow but blocked because the Browser JavaScript execution tool was not exposed in this session after tool discovery. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/ai/tasks/done/TASK-160-home-server-card-bottom-actions.md b/ai/tasks/done/TASK-160-home-server-card-bottom-actions.md new file mode 100644 index 0000000..881d61f --- /dev/null +++ b/ai/tasks/done/TASK-160-home-server-card-bottom-actions.md @@ -0,0 +1,164 @@ +--- +id: TASK-160 +title: Home server card bottom actions +status: done +type: frontend +team: Frontend Senior +supporting_teams: [Experto en interfaz] +roadmap_item: foundation +priority: high +--- + +# TASK-160 - Home server card bottom actions + +## Goal + +Cleanly restructure the home server card action layout so only Historico and Partida actual render, positioned bottom-right aligned with the map card, without changing unrelated UI. + +## Background + +The home page server cards are rendered from `frontend/assets/js/main.js`. + +Current user feedback: + +- The home card buttons are still too high. +- The user wants the buttons moved "dos pasos al div de abajo con el mapa". +- The buttons must be located at the far right in the lower area, aligned with the map card. +- The layout should be more compact. +- Do not touch anything else. + +Current known issue: + +- `renderServerAction(server)` in `frontend/assets/js/main.js` still renders "Scoreboard publico". +- CSS/HTML workarounds have been used to hide/rename buttons. +- The user wants only: + - Historico + - Partida actual + +## Files to Read First + +- `AGENTS.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` +- `frontend/index.html` +- `frontend/assets/js/main.js` +- `frontend/assets/css/styles.css` + +Inspect the rendered server card structure, quickfact/map markup, trusted action URL mapping, and any existing inline or stylesheet button workarounds before changing code. + +## Expected Files to Modify + +Allowed changes: + +- `frontend/assets/js/main.js` +- `frontend/assets/css/styles.css` +- `frontend/index.html` only to remove previous inline CSS workarounds if present +- no backend changes +- no tests unless needed +- node validation + +## Constraints - DO NOT BREAK + +- Do not change backend. +- Do not change current-match page. +- Do not change historical page behavior. +- Do not change server polling logic. +- Do not reintroduce Scoreboard publico on home server cards. +- Do not remove trusted URL mappings if used elsewhere unless unused. +- Do not alter the rest of the home page layout/content. +- Preserve responsive behavior. +- Commit and push after implementation. + +## Implementation Requirements + +### 1. Remove structural rendering of Scoreboard publico + +`renderServerAction(server)` should only output: + +- Historico +- Partida actual + +### 2. Rename the rendered history action + +Rename "Nuestro historico" to "Historico" in the rendered markup. + +### 3. Avoid CSS-only hide/rename hacks + +- Remove old CSS workarounds that hide first button or replace text through pseudo-elements if present. +- Make the JS markup itself correct. + +### 4. Move buttons to the lower-right area + +- The map quickfact card remains lower-left. +- The action buttons should be in the same lower row/block, aligned far right. +- Avoid large empty bottom-right space. +- Keep card height compact. + +### 5. Suggested structure + +- Card top: eyebrow, server name, status/population. +- Card bottom row: map card left, action buttons right. +- Do not make the map card huge. +- Buttons should not float in the middle of the card. + +### 6. Responsive behavior + +- On desktop, bottom row is two columns: + - map left + - actions right +- On mobile/narrow widths, stack safely: + - map + - actions + +## Validation + +Run: + +- `node --check frontend/assets/js/main.js` +- `git diff --check` +- rebuild frontend + +Before completing the task also confirm that `git diff --name-only` matches the expected scope. + +## Manual Verification Steps + +- Open `http://localhost:8080/`. +- Verify each server card shows only: + - Historico + - Partida actual +- Verify Scoreboard publico is not rendered. +- Verify buttons are bottom-right aligned with the map card. +- Verify map card remains compact. +- Verify card layout is not broken on narrow viewport. +- Verify Historico opens: + - `historico.html?server=comunidad-hispana-01` + - `historico.html?server=comunidad-hispana-02` +- Verify Partida actual opens: + - `partida-actual.html?server=comunidad-hispana-01` + - `partida-actual.html?server=comunidad-hispana-02` + +## Expected Outcome + +The home server cards have a clean structural layout with only Historico and Partida actual positioned at the lower-right aligned with the map card. + +## Outcome + +Structural cleanup: + +- Home server card actions now live in a dedicated bottom row beside the map quickfact rather than inside the status column. +- The rendered card action markup keeps only `Historico` and `Partida actual`; trusted URL mappings stay intact for those actions. +- Removed the inline server-card layout workaround from `frontend/index.html` and moved the bottom-row layout rules into `frontend/assets/css/styles.css`, with a stacked narrow layout. + +Validation performed: + +- `node --check frontend/assets/js/main.js` +- `git diff --check` +- `docker compose build frontend` +- Checked the existing local frontend and backend endpoints were serving on `127.0.0.1:8080` and `127.0.0.1:8000`. +- Rendered Browser verification was attempted through the required Browser workflow but blocked because the Browser JavaScript execution tool was not exposed in this session after tool discovery. + +## Change Budget + +- Prefer fewer than 5 modified files. +- Prefer changes under 200 lines when feasible. +- Split the work into follow-up tasks if limits are exceeded. diff --git a/ai/tasks/done/TASK-POSTGRES-HISTORICAL-PHASE-2.md b/ai/tasks/done/TASK-POSTGRES-HISTORICAL-PHASE-2.md new file mode 100644 index 0000000..2bbe699 --- /dev/null +++ b/ai/tasks/done/TASK-POSTGRES-HISTORICAL-PHASE-2.md @@ -0,0 +1,135 @@ +--- +id: TASK-POSTGRES-HISTORICAL-PHASE-2 +title: Migrate displayed historical data to PostgreSQL +status: done +type: backend +team: Backend Senior +supporting_teams: + - Arquitecto de Base de Datos + - Arquitecto Python +roadmap_item: foundation +priority: high +--- + +# TASK-POSTGRES-HISTORICAL-PHASE-2 - Migrate displayed historical data to PostgreSQL + +## Goal + +Make PostgreSQL authoritative for historical and server data currently displayed +by the frontend, add an idempotent SQLite/file migration command, and close the +phase-1 datetime serialization and materialized detail lookup regressions. + +## Context + +Phase 1 moved the live RCON historical write path to PostgreSQL, but historical +frontend pages still read older SQLite and file-backed data for ranking, +scoreboard fallback, snapshots, server cache, and some match-detail continuity. +PostgreSQL reads also expose native datetime values that can terminate JSON API +responses. The phase-2 migration must preserve existing historical URLs and safe +scoreboard links without making SQLite the active source of truth again. + +## Steps + +1. Inspect the PostgreSQL RCON schema, SQLite historical schema, snapshot/server + reads, and affected API routes. +2. Add PostgreSQL-backed displayed read models plus deterministic migration from + existing SQLite/files. +3. Fix JSON-safe datetime handling and recent/detail materialized match + consistency with focused regression tests. +4. Update diagnostics and docs, then run the requested local and Docker checks + where available. + +## Files to Read First + +- `ai/architecture-index.md` +- `ai/repo-context.md` +- `backend/app/postgres_rcon_storage.py` +- `backend/app/historical_api.py` +- `backend/app/historical_snapshots.py` +- `backend/app/storage_diagnostics.py` + +## Expected Files to Modify + +- `backend/app/postgres_rcon_storage.py` +- `backend/app/sqlite_to_postgres_migration.py` +- `backend/app/storage_diagnostics.py` +- backend historical/server storage and API modules needed to move displayed + SQLite/file reads to PostgreSQL +- focused backend tests for datetime and detail lookup regressions +- `backend/README.md` +- `docs/decisions.md` + +## Constraints + +- Do not return displayed historical data to SQLite as the main fix. +- Preserve old match ids, match keys, public scoreboard safe URLs, and existing + historical data where present. +- Do not re-enable Comunidad Hispana #03 as an active configured target. +- Keep Elo/MMR out of phase 2 unless a visible API requires its PostgreSQL data. +- Do not modify unrelated frontend behavior or commit runtime database files. + +## Validation + +- Run the requested compile, diagnostics, migration, Node syntax, RCON pipeline, + integration, Docker Compose, container diagnostics, materialization, and HTTP + smoke commands where the local environment permits them. +- Review `git diff --name-only` and confirm changed files match this task. + +## Outcome + +Implemented PostgreSQL phase 2 for the displayed historical surface. The new +displayed PostgreSQL schema stores migrated public-scoreboard match/player +tables, historical snapshot payloads, live server cache rows and player-event +ledger rows alongside the phase-1 RCON tables. API storage facades now read +those PostgreSQL stores when `HLL_BACKEND_DATABASE_URL` is configured; SQLite +and snapshot JSON remain migration inputs or explicit local fixtures. + +`python -m app.sqlite_to_postgres_migration` now discovers legacy SQLite files +and historical snapshot JSON under the configured backend data directory, copies +stable IDs/keys with idempotent conflict handling, advances PostgreSQL +sequences, skips visible `comunidad-hispana-03` legacy scope, preserves safe +scoreboard URLs and prints JSON row counts/errors. A repeat container run read +the existing source rows and reported zero inserted rows with no errors. + +Required fixes included global HTTP JSON encoding for PostgreSQL `date` and +`datetime` values, a materialized detail lookup guard for match keys that embed +the requested server key, reduced repeated RCON PostgreSQL read-time DDL to +avoid schema-lock chains during materialization/diagnostics, and focused tests +for JSON encoding plus recent/detail match-id consistency. + +Post-migration container diagnostics showed PostgreSQL counts including: + +- `admin_log_events=21242` +- `materialized_matches=58` +- `player_stats=3824` +- `public_scoreboard_historical_matches=10030` +- ranking source stats `1090704` +- displayed snapshots `51` +- player-event ledger `14715` +- safe scoreboard candidates `400` + +Remaining SQLite/file-backed boundaries are non-visible phase-3 scope only: +public-scoreboard import run/backfill bookkeeping and paused Elo/MMR tables. + +Validation completed: + +- `python -m compileall backend/app` +- `python -m unittest discover -s tests -p test_json_serialization.py` + from `backend/` +- `python -m app.storage_diagnostics` locally and in the backend container +- `node --check` for the four requested frontend JavaScript files +- `powershell -ExecutionPolicy Bypass -File scripts/run-rcon-data-pipeline-tests.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- requested advanced Docker Compose down/up/build/ps flow +- container migration, diagnostics, AdminLog ingestion and materialization +- requested HTTP probes for `/health`, recent matches and both known detail keys +- direct PostgreSQL-backed snapshot and direct monthly leaderboard HTTP probes + +The RCON pipeline script still prints existing SQLite `ResourceWarning` noise +from its unittest fallback suites, but the script and suites passed. + +## Change Budget + +This migration intentionally crosses backend schema, read models, diagnostics, +tests, and docs. Keep each change tied to displayed data ownership or the +required migration/serialization/detail consistency fixes. 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/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/.env.example b/backend/.env.example index f4f5c3d..c252827 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,7 +1,8 @@ HLL_BACKEND_HOST=0.0.0.0 HLL_BACKEND_PORT=8000 HLL_BACKEND_STORAGE_PATH=/app/data/hll_vietnam_dev.sqlite3 -HLL_BACKEND_ALLOWED_ORIGINS=http://127.0.0.1:8080,http://localhost:8080 +HLL_BACKEND_DATABASE_URL=postgresql://hll_vietnam:hll_vietnam_dev@postgres:5432/hll_vietnam +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 adbcaac..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` @@ -97,6 +101,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 +137,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 @@ -160,8 +180,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` @@ -448,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 @@ -1404,7 +1435,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 @@ -1563,6 +1596,41 @@ Estado real a fecha de esta fase: RCON-backed primario y usar `public-scoreboard` solo como suplemento/fallback para estadisticas por jugador sin paridad RCON +## PostgreSQL Phase 2 Displayed Data Migration + +Cuando `HLL_BACKEND_DATABASE_URL` esta configurado, los endpoints visibles de +historico y el cache mostrado por `/api/servers` leen PostgreSQL. SQLite y los +JSON legacy quedan como fuente de migracion o fixture explicito con `db_path`. + +Migracion idempotente: + +```powershell +cd backend +python -m app.sqlite_to_postgres_migration +python -m app.storage_diagnostics +``` + +La salida JSON de `sqlite_to_postgres_migration` lista rutas fuente, dominios y +tablas migradas, filas leidas, insertadas, actualizadas, omitidas y errores. +La migracion conserva `external_match_id`, IDs legacy y `match_key` RCON para +que URLs de detalle existentes sigan resolviendo. Tambien copia candidatos y +URLs seguras de scoreboard; no vuelve a activar filas visibles de +`comunidad-hispana-03`. + +Paridad minima a revisar en `storage_diagnostics`: + +- `admin_log_events`, `materialized_matches`, `player_stats` +- `public_scoreboard_historical_matches` +- fuentes de rankings semanales y mensuales +- `server_summary_cache`, `server_snapshots`, `player_event_ledger` +- `scoreboard_candidates` +- ultimas partidas materializadas y ultimos eventos AdminLog `match_end` + +Fuera de phase 2 quedan checkpoints/runs de ingesta publica que no se muestran +en frontend y Elo/MMR pausado. Si un endpoint de mantenimiento recibe un +`db_path` explicito, sigue trabajando contra SQLite para migracion, tests o +compatibilidad operativa controlada. + ## Elo/MMR Monthly Ranking Se añade una primera base operativa inspirada en el documento diff --git a/backend/app/config.py b/backend/app/config.py index a76ff36..fa8f447 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -35,14 +35,19 @@ 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 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", ) @@ -85,6 +90,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( @@ -217,20 +236,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: @@ -281,30 +320,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: @@ -458,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/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 diff --git a/backend/app/historical_runner.py b/backend/app/historical_runner.py index d822286..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)) + _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] @@ -276,6 +304,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 @@ -349,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_snapshot_storage.py b/backend/app/historical_snapshot_storage.py index f3f596a..d81706c 100644 --- a/backend/app/historical_snapshot_storage.py +++ b/backend/app/historical_snapshot_storage.py @@ -6,7 +6,7 @@ import json 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 .historical_models import HistoricalSnapshotRecord from .historical_snapshots import validate_snapshot_identity @@ -46,6 +46,22 @@ def persist_historical_snapshot( raise ValueError("server_key is required for historical snapshots.") validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import persist_snapshot_record + + return persist_snapshot_record( + { + "server_key": normalized_server_key, + "snapshot_type": snapshot_type, + "metric": metric, + "window": window, + "generated_at": generated_at or datetime.now(timezone.utc), + "source_range_start": source_range_start, + "source_range_end": source_range_end, + "is_stale": is_stale, + "payload": payload, + } + ) snapshots_root = initialize_historical_snapshot_storage(db_path=db_path) generated_at_value = _as_utc(generated_at or datetime.now(timezone.utc)) payload_json = json.dumps(payload, ensure_ascii=True) @@ -148,6 +164,15 @@ def get_historical_snapshot( ) -> dict[str, object] | None: """Return one persisted snapshot and decoded payload, if present.""" validate_snapshot_identity(snapshot_type=snapshot_type, metric=metric) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import get_snapshot + + return get_snapshot( + server_key=server_key, + snapshot_type=snapshot_type, + metric=metric, + window=window, + ) snapshots_root = resolve_historical_snapshot_storage_path(db_path=db_path) snapshot_path = _build_snapshot_path( snapshots_root=snapshots_root, diff --git a/backend/app/historical_snapshots.py b/backend/app/historical_snapshots.py index 0182339..f54c0dd 100644 --- a/backend/app/historical_snapshots.py +++ b/backend/app/historical_snapshots.py @@ -2,11 +2,12 @@ from __future__ import annotations +import json import sqlite3 from datetime import datetime, timezone from pathlib import Path -from .config import get_historical_data_source_kind +from .config import get_database_url, get_historical_data_source_kind from .data_sources import SOURCE_KIND_RCON, get_rcon_historical_read_model from .historical_storage import ( ALL_SERVERS_SLUG, @@ -66,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",) @@ -135,9 +135,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 +154,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 +165,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 +174,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 +184,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 +195,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 +217,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 +243,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 +257,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 +266,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 +276,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 +286,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, @@ -402,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, @@ -435,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, @@ -678,7 +734,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 +762,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], @@ -726,6 +782,10 @@ def _build_player_event_scope_where(*, server_key: str) -> tuple[str, list[objec def _connect(db_path: Path) -> sqlite3.Connection: + if get_database_url(): + from .postgres_display_storage import connect_postgres_compat + + return connect_postgres_compat() connection = sqlite3.connect(db_path) connection.row_factory = sqlite3.Row return connection @@ -749,3 +809,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 379773d..db7dd57 100644 --- a/backend/app/historical_storage.py +++ b/backend/app/historical_storage.py @@ -12,10 +12,12 @@ from .config import ( get_historical_weekly_fallback_max_weekday, get_historical_weekly_fallback_min_matches, get_storage_path, + use_postgres_rcon_storage, ) from .historical_models import HistoricalServerDefinition from .monthly_mvp import build_monthly_mvp_rankings from .monthly_mvp_v2 import build_monthly_mvp_v2_rankings +from .player_external_profiles import build_external_player_profile_fields from .scoreboard_origins import ( list_trusted_public_scoreboard_origins, resolve_trusted_scoreboard_match_url, @@ -739,6 +741,10 @@ def list_recent_historical_matches( db_path: Path | None = None, ) -> list[dict[str, object]]: """Return recent persisted matches grouped for the historical API layer.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_recent_scoreboard_matches + + return list_recent_scoreboard_matches(server_slug=server_slug, limit=limit) resolved_path = initialize_historical_storage(db_path=db_path) where_clause = "" params: list[object] = [] @@ -821,6 +827,13 @@ def get_historical_match_detail( normalized_match_id = _stringify(match_id) if not normalized_server_slug or not normalized_match_id: return None + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import get_scoreboard_match_detail + + return get_scoreboard_match_detail( + server_slug=normalized_server_slug, + match_id=normalized_match_id, + ) resolved_path = initialize_historical_storage(db_path=db_path) with _connect(resolved_path) as connection: row = connection.execute( @@ -860,6 +873,7 @@ def get_historical_match_detail( SELECT historical_players.display_name, historical_players.stable_player_key, + historical_players.steam_id, historical_player_match_stats.team_side, historical_player_match_stats.level, historical_player_match_stats.kills, @@ -913,6 +927,7 @@ def get_historical_match_detail( "name": player_row["display_name"], "stable_player_key": player_row["stable_player_key"], "team_side": player_row["team_side"], + **build_external_player_profile_fields(steam_id=player_row["steam_id"]), "level": _coerce_int(player_row["level"]), "kills": _coerce_int(player_row["kills"]), "deaths": _coerce_int(player_row["deaths"]), @@ -939,6 +954,10 @@ def list_historical_server_summaries( db_path: Path | None = None, ) -> list[dict[str, object]]: """Return aggregate historical metrics per server.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_scoreboard_server_summaries + + return list_scoreboard_server_summaries(server_slug=server_slug) resolved_path = initialize_historical_storage(db_path=db_path) if _is_all_servers_selector(server_slug): return [_build_all_servers_summary(db_path=resolved_path)] @@ -1247,6 +1266,15 @@ def list_weekly_leaderboard( db_path: Path | None = None, ) -> dict[str, object]: """Return ranked weekly leaderboard totals from persisted historical match stats.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_scoreboard_leaderboard + + return list_scoreboard_leaderboard( + timeframe="weekly", + metric=metric, + server_id=server_id, + limit=limit, + ) resolved_path = initialize_historical_storage(db_path=db_path) aggregate_all_servers = _is_all_servers_selector(server_id) current_time = datetime.now(timezone.utc) @@ -1432,6 +1460,15 @@ def list_monthly_leaderboard( db_path: Path | None = None, ) -> dict[str, object]: """Return ranked monthly leaderboard totals from persisted historical match stats.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_scoreboard_leaderboard + + return list_scoreboard_leaderboard( + timeframe="monthly", + metric=metric, + server_id=server_id, + limit=limit, + ) resolved_path = initialize_historical_storage(db_path=db_path) aggregate_all_servers = _is_all_servers_selector(server_id) current_time = datetime.now(timezone.utc) @@ -1805,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 @@ -1826,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 @@ -1864,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 @@ -1983,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} @@ -1998,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/main.py b/backend/app/main.py index 18e3265..262e592 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,10 +3,12 @@ from __future__ import annotations import json +from datetime import date, datetime 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 @@ -23,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, @@ -38,7 +48,7 @@ class HealthHandler(BaseHTTPRequestHandler): return def _write_json(self, status: HTTPStatus, payload: dict[str, object]) -> None: - body = json.dumps(payload).encode("utf-8") + body = json.dumps(payload, default=_json_default).encode("utf-8") self.send_response(status) self._send_default_headers(content_length=len(body)) self.end_headers() @@ -61,6 +71,13 @@ def create_server() -> ThreadingHTTPServer: return ThreadingHTTPServer((host, port), HealthHandler) +def _json_default(value: object) -> str: + """Serialize PostgreSQL date/time values before they can abort an HTTP response.""" + if isinstance(value, (date, datetime)): + return value.isoformat() + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + + def run() -> None: """Start the local bootstrap server.""" host, port = get_bind_address() diff --git a/backend/app/payloads.py b/backend/app/payloads.py index 2969d42..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, @@ -49,6 +50,8 @@ 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, list_current_match_player_stats from .scoreboard_origins import get_trusted_public_scoreboard_origin from .storage import list_latest_snapshots, list_server_history, list_snapshot_history @@ -237,6 +240,250 @@ def build_server_detail_history_payload( } +def build_current_match_payload(*, server_slug: str) -> dict[str, object]: + """Return the live page projection for one trusted active server.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + 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 = _find_current_match_snapshot_item(server_data.get("items", []), origin) + return { + "status": "ok", + "data": { + "found": item is not None, + "server_slug": origin.slug, + "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, + }, + } + + +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]: + """Return normalized AdminLog kill rows for one trusted current-match page.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + if origin is None: + raise ValueError("Unsupported current match server.") + feed = list_current_match_kill_feed( + server_key=origin.slug, + limit=limit, + since_event_id=since_event_id, + ) + return { + "status": "ok", + "data": { + "server_slug": origin.slug, + "server_name": origin.display_name, + **feed, + }, + } + + +def build_current_match_player_stats_payload(*, server_slug: str) -> dict[str, object]: + """Return current player stats only when safe AdminLog evidence exists.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + if origin is None: + raise ValueError("Unsupported current match server.") + stats = list_current_match_player_stats(server_key=origin.slug) + return { + "status": "ok", + "data": { + "server_slug": origin.slug, + "server_name": origin.display_name, + **stats, + }, + } + + +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/player_event_aggregates.py b/backend/app/player_event_aggregates.py index d95449d..e6c1592 100644 --- a/backend/app/player_event_aggregates.py +++ b/backend/app/player_event_aggregates.py @@ -5,7 +5,7 @@ from __future__ import annotations import sqlite3 from pathlib import Path -from .config import get_storage_path +from .config import get_database_url, get_storage_path from .player_event_storage import initialize_player_event_storage @@ -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 = ?") @@ -249,6 +252,10 @@ def _build_common_where( def _connect(db_path: Path) -> sqlite3.Connection: + if get_database_url(): + from .postgres_display_storage import connect_postgres_compat + + return connect_postgres_compat() connection = sqlite3.connect(db_path or get_storage_path()) connection.row_factory = sqlite3.Row return connection diff --git a/backend/app/player_event_storage.py b/backend/app/player_event_storage.py index c55f5b4..1997583 100644 --- a/backend/app/player_event_storage.py +++ b/backend/app/player_event_storage.py @@ -7,7 +7,11 @@ from collections.abc import Iterable from datetime import datetime, timedelta, timezone from pathlib import Path -from .config import get_player_event_refresh_overlap_hours, get_storage_path +from .config import ( + get_player_event_refresh_overlap_hours, + get_storage_path, + use_postgres_rcon_storage, +) from .player_event_models import PlayerEventRecord from .sqlite_utils import connect_sqlite_writer @@ -95,6 +99,10 @@ def upsert_player_events( db_path: Path | None = None, ) -> dict[str, int]: """Insert normalized events idempotently into the raw ledger.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import upsert_player_event_rows + + return upsert_player_event_rows(events) resolved_path = initialize_player_event_storage(db_path=db_path) inserted = 0 duplicates = 0 diff --git a/backend/app/player_external_profiles.py b/backend/app/player_external_profiles.py new file mode 100644 index 0000000..032f57e --- /dev/null +++ b/backend/app/player_external_profiles.py @@ -0,0 +1,65 @@ +"""Safe external profile fields derived from captured player identifiers.""" + +from __future__ import annotations + +import re + + +_STEAM_ID64_RE = re.compile(r"^\d{17}$") +_EPIC_ID_RE = re.compile(r"^[0-9a-f]{32}$", re.IGNORECASE) + + +def build_external_player_profile_fields( + *, + player_id: object = None, + steam_id: object = None, +) -> dict[str, object]: + """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 { + "steam_id_64": steam_id_64, + "platform": "steam", + "external_profile_links": { + "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", + }, + } + + 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: + normalized = str(value or "").strip() + 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 normalize_epic_id(normalized_player_id): + return "epic" + return "unknown" diff --git a/backend/app/postgres_display_storage.py b/backend/app/postgres_display_storage.py new file mode 100644 index 0000000..36898b1 --- /dev/null +++ b/backend/app/postgres_display_storage.py @@ -0,0 +1,929 @@ +"""PostgreSQL read/write storage for data displayed outside the RCON write path.""" + +from __future__ import annotations + +import json +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Iterable, Mapping + +from .config import get_database_url, get_historical_weekly_fallback_max_weekday +from .historical_models import HistoricalSnapshotRecord +from .player_external_profiles import build_external_player_profile_fields +from .scoreboard_origins import resolve_trusted_scoreboard_match_url + + +ALL_SERVERS_SLUG = "all-servers" +ALL_SERVERS_DISPLAY_NAME = "Todos" +SUMMARY_SNAPSHOT_LIMIT = 6 + + +DISPLAY_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS game_sources ( + id BIGSERIAL PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + provider_kind TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS servers ( + id BIGSERIAL PRIMARY KEY, + game_source_id BIGINT NOT NULL REFERENCES game_sources(id), + external_server_id TEXT, + server_name TEXT NOT NULL, + region TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(game_source_id, external_server_id) +); +CREATE TABLE IF NOT EXISTS server_snapshots ( + id BIGSERIAL PRIMARY KEY, + server_id BIGINT NOT NULL REFERENCES servers(id), + captured_at TEXT NOT NULL, + status TEXT NOT NULL, + players INTEGER, + max_players INTEGER, + current_map TEXT, + source_name TEXT NOT NULL, + snapshot_origin TEXT, + source_ref TEXT, + raw_payload_ref TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(server_id, captured_at, source_name, source_ref) +); +CREATE INDEX IF NOT EXISTS idx_pg_server_snapshots_server_time +ON server_snapshots(server_id, captured_at DESC); + +CREATE TABLE IF NOT EXISTS historical_servers ( + id BIGSERIAL PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + scoreboard_base_url TEXT NOT NULL UNIQUE, + server_number INTEGER, + source_kind TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS historical_maps ( + id BIGSERIAL PRIMARY KEY, + external_map_id TEXT UNIQUE, + map_name TEXT, + pretty_name TEXT, + game_mode TEXT, + image_name TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS historical_matches ( + id BIGSERIAL PRIMARY KEY, + historical_server_id BIGINT NOT NULL REFERENCES historical_servers(id), + external_match_id TEXT NOT NULL, + historical_map_id BIGINT REFERENCES historical_maps(id), + created_at_source TEXT, + started_at TEXT, + ended_at TEXT, + map_name TEXT, + map_pretty_name TEXT, + game_mode TEXT, + image_name TEXT, + allied_score INTEGER, + axis_score INTEGER, + last_seen_at TEXT NOT NULL, + raw_payload_ref TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(historical_server_id, external_match_id) +); +CREATE TABLE IF NOT EXISTS historical_players ( + id BIGSERIAL PRIMARY KEY, + stable_player_key TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + steam_id TEXT, + source_player_id TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS historical_player_match_stats ( + id BIGSERIAL PRIMARY KEY, + historical_match_id BIGINT NOT NULL REFERENCES historical_matches(id), + historical_player_id BIGINT NOT NULL REFERENCES historical_players(id), + match_player_ref TEXT, + team_side TEXT, + level INTEGER, + kills INTEGER, + deaths INTEGER, + teamkills INTEGER, + time_seconds INTEGER, + kills_per_minute DOUBLE PRECISION, + deaths_per_minute DOUBLE PRECISION, + kill_death_ratio DOUBLE PRECISION, + combat INTEGER, + offense INTEGER, + defense INTEGER, + support INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(historical_match_id, historical_player_id) +); +CREATE INDEX IF NOT EXISTS idx_pg_historical_matches_server_end +ON historical_matches(historical_server_id, ended_at DESC, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_pg_historical_player_stats_match +ON historical_player_match_stats(historical_match_id); + +CREATE TABLE IF NOT EXISTS displayed_historical_snapshots ( + server_key TEXT NOT NULL, + snapshot_type TEXT NOT NULL, + metric TEXT NOT NULL DEFAULT '', + snapshot_window TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL, + generated_at TEXT NOT NULL, + source_range_start TEXT, + source_range_end TEXT, + is_stale BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(server_key, snapshot_type, metric, snapshot_window) +); + +CREATE TABLE IF NOT EXISTS player_event_raw_ledger ( + id BIGSERIAL PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + occurred_at TEXT, + server_slug TEXT NOT NULL, + external_match_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_ref TEXT, + raw_event_ref TEXT, + killer_player_key TEXT, + killer_display_name TEXT, + victim_player_key TEXT, + victim_display_name TEXT, + weapon_name TEXT, + weapon_category TEXT, + kill_category TEXT, + is_teamkill BOOLEAN NOT NULL DEFAULT FALSE, + event_value INTEGER NOT NULL DEFAULT 1, + inserted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_pg_player_event_raw_occurred_at +ON player_event_raw_ledger(occurred_at DESC); +""" + + +def initialize_postgres_display_storage() -> None: + with connect_postgres() as connection: + connection.execute(DISPLAY_SCHEMA_SQL) + + +def connect_postgres(): + try: + import psycopg + from psycopg.rows import dict_row + except ImportError as error: # pragma: no cover - 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 displayed PostgreSQL storage.") + return psycopg.connect(database_url, row_factory=dict_row) + + +class PostgresCompatConnection: + """Small placeholder shim for SQLite-shaped displayed read queries.""" + + def __init__(self, connection: Any): + self.connection = connection + + def execute(self, sql: str, params: Iterable[object] | None = None): + return self.connection.execute(sql.replace("?", "%s"), tuple(params or ())) + + +@contextmanager +def connect_postgres_compat(): + initialize_postgres_display_storage() + with connect_postgres() as connection: + yield PostgresCompatConnection(connection) + + +def persist_snapshot_record(snapshot: Mapping[str, object]) -> HistoricalSnapshotRecord: + initialize_postgres_display_storage() + generated_at = _iso(snapshot.get("generated_at")) or _utc_now_iso() + 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=(",", ":"), + default=_json_payload_default, + ) + with connect_postgres() as connection: + connection.execute( + """ + INSERT INTO displayed_historical_snapshots ( + server_key, snapshot_type, metric, snapshot_window, payload_json, generated_at, + source_range_start, source_range_end, is_stale + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT(server_key, snapshot_type, metric, snapshot_window) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + generated_at = EXCLUDED.generated_at, + source_range_start = EXCLUDED.source_range_start, + source_range_end = EXCLUDED.source_range_end, + is_stale = EXCLUDED.is_stale, + updated_at = CURRENT_TIMESTAMP + """, + ( + str(snapshot["server_key"]), + str(snapshot["snapshot_type"]), + metric, + window, + payload_json, + generated_at, + _iso(snapshot.get("source_range_start")), + _iso(snapshot.get("source_range_end")), + bool(snapshot.get("is_stale", False)), + ), + ) + return HistoricalSnapshotRecord( + server_key=str(snapshot["server_key"]), + snapshot_type=str(snapshot["snapshot_type"]), + metric=metric or None, + window=window or None, + payload_json=payload_json, + generated_at=_parse_datetime(generated_at) or datetime.now(timezone.utc), + source_range_start=_parse_datetime(_iso(snapshot.get("source_range_start"))), + source_range_end=_parse_datetime(_iso(snapshot.get("source_range_end"))), + is_stale=bool(snapshot.get("is_stale", False)), + ) + + +def get_snapshot( + *, + server_key: str, + snapshot_type: str, + metric: str | None, + window: str | None, +) -> dict[str, object] | None: + initialize_postgres_display_storage() + with connect_postgres() as connection: + row = connection.execute( + """ + SELECT * + FROM displayed_historical_snapshots + WHERE server_key = %s AND snapshot_type = %s AND metric = %s AND snapshot_window = %s + """, + (server_key, snapshot_type, metric or "", window or ""), + ).fetchone() + if not row: + return None + return { + "server_key": row["server_key"], + "snapshot_type": row["snapshot_type"], + "metric": row["metric"] or None, + "window": row["snapshot_window"] or None, + "generated_at": row["generated_at"], + "source_range_start": row["source_range_start"], + "source_range_end": row["source_range_end"], + "is_stale": bool(row["is_stale"]), + "payload": json.loads(row["payload_json"]), + } + + +def list_latest_server_snapshots() -> list[dict[str, object]]: + initialize_postgres_display_storage() + with connect_postgres() as connection: + rows = connection.execute( + """ + SELECT s.id AS server_id, s.external_server_id, s.server_name, s.region, + g.slug AS context, snap.source_name, snap.snapshot_origin, + snap.source_ref, snap.captured_at, snap.status, snap.players, + snap.max_players, snap.current_map + FROM servers AS s + JOIN game_sources AS g ON g.id = s.game_source_id + JOIN server_snapshots AS snap ON snap.server_id = s.id + JOIN ( + SELECT server_id, MAX(captured_at) AS captured_at + FROM server_snapshots GROUP BY server_id + ) AS latest ON latest.server_id = snap.server_id + AND latest.captured_at = snap.captured_at + ORDER BY s.server_name ASC + """ + ).fetchall() + return [_attach_server_history(connection, dict(row)) for row in rows] + + +def persist_server_snapshots( + snapshots: Iterable[Mapping[str, object]], + *, + source_name: str, + captured_at: str, + game_source: Mapping[str, str], +) -> dict[str, object]: + initialize_postgres_display_storage() + persisted = 0 + with connect_postgres() as connection: + source = connection.execute( + """ + INSERT INTO game_sources (slug, display_name, provider_kind, is_active) + VALUES (%s, %s, %s, TRUE) + ON CONFLICT(slug) DO UPDATE SET + display_name = EXCLUDED.display_name, + provider_kind = EXCLUDED.provider_kind, + is_active = TRUE, + updated_at = CURRENT_TIMESTAMP + RETURNING id + """, + (game_source["slug"], game_source["display_name"], game_source["provider_kind"]), + ).fetchone() + for snapshot in snapshots: + external_server_id = str(snapshot.get("external_server_id") or "").strip() + if not external_server_id: + external_server_id = _fallback_external_id(snapshot.get("server_name")) + server = connection.execute( + """ + INSERT INTO servers ( + game_source_id, external_server_id, server_name, region, + first_seen_at, last_seen_at + ) VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT(game_source_id, external_server_id) DO UPDATE SET + server_name = EXCLUDED.server_name, + region = EXCLUDED.region, + last_seen_at = EXCLUDED.last_seen_at, + updated_at = CURRENT_TIMESTAMP + RETURNING id + """, + ( + source["id"], + external_server_id, + str(snapshot.get("server_name") or "Unknown server"), + snapshot.get("region"), + captured_at, + captured_at, + ), + ).fetchone() + connection.execute( + """ + INSERT INTO server_snapshots ( + server_id, captured_at, status, players, max_players, current_map, + source_name, snapshot_origin, source_ref, raw_payload_ref + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NULL) + ON CONFLICT(server_id, captured_at, source_name, source_ref) DO UPDATE SET + status = EXCLUDED.status, + players = EXCLUDED.players, + max_players = EXCLUDED.max_players, + current_map = EXCLUDED.current_map, + snapshot_origin = EXCLUDED.snapshot_origin + """, + ( + server["id"], + captured_at, + snapshot.get("status") or "unknown", + snapshot.get("players"), + snapshot.get("max_players"), + snapshot.get("current_map"), + snapshot.get("source_name") or source_name, + snapshot.get("snapshot_origin"), + snapshot.get("source_ref") or snapshot.get("source_name") or source_name, + ), + ) + persisted += 1 + return { + "db_path": "postgresql", + "captured_at": captured_at, + "persisted_snapshots": persisted, + "game_source_slug": game_source["slug"], + } + + +def upsert_player_event_rows(events: Iterable[object]) -> dict[str, int]: + initialize_postgres_display_storage() + inserted = 0 + duplicates = 0 + with connect_postgres() as connection: + for event in events: + row = connection.execute( + """ + INSERT INTO player_event_raw_ledger ( + event_id, event_type, occurred_at, server_slug, external_match_id, + source_kind, source_ref, raw_event_ref, killer_player_key, + killer_display_name, victim_player_key, victim_display_name, + weapon_name, weapon_category, kill_category, is_teamkill, event_value + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT(event_id) DO NOTHING + RETURNING id + """, + ( + event.event_id, + event.event_type, + event.occurred_at, + event.server_slug, + event.external_match_id, + event.source_kind, + event.source_ref, + event.raw_event_ref, + event.killer_player_key, + event.killer_display_name, + event.victim_player_key, + event.victim_display_name, + event.weapon_name, + event.weapon_category, + event.kill_category, + bool(event.is_teamkill), + max(1, int(event.event_value)), + ), + ).fetchone() + inserted += int(bool(row)) + duplicates += int(not row) + return {"events_inserted": inserted, "duplicate_events": duplicates} + + +def list_server_snapshot_history(*, server_id: str | None = None, limit: int) -> list[dict[str, object]]: + initialize_postgres_display_storage() + where = "" + params: list[object] = [] + if server_id: + if server_id.strip().isdigit(): + where = "WHERE s.id = %s" + params.append(int(server_id)) + else: + where = "WHERE s.external_server_id = %s" + params.append(server_id.strip()) + with connect_postgres() as connection: + rows = connection.execute( + f""" + SELECT s.id AS server_id, s.external_server_id, s.server_name, s.region, + g.slug AS context, snap.source_name, snap.snapshot_origin, + snap.source_ref, snap.captured_at, snap.status, snap.players, + snap.max_players, snap.current_map + FROM server_snapshots AS snap + JOIN servers AS s ON s.id = snap.server_id + JOIN game_sources AS g ON g.id = s.game_source_id + {where} + ORDER BY snap.captured_at DESC, s.server_name ASC + LIMIT %s + """, + (*params, limit), + ).fetchall() + return [dict(row) for row in rows] + + +def list_recent_scoreboard_matches(*, server_slug: str | None, limit: int) -> list[dict[str, object]]: + initialize_postgres_display_storage() + where = "" + params: list[object] = [] + if server_slug and server_slug != ALL_SERVERS_SLUG: + where = "WHERE hs.slug = %s" + params.append(server_slug) + with connect_postgres() as connection: + rows = connection.execute( + f""" + SELECT hs.slug AS server_slug, hs.display_name AS server_name, + hm.external_match_id, hm.started_at, hm.ended_at, + hm.map_pretty_name, hm.map_name, hm.allied_score, hm.axis_score, + hm.raw_payload_ref, COUNT(stats.id) AS player_count + FROM historical_matches AS hm + JOIN historical_servers AS hs ON hs.id = hm.historical_server_id + LEFT JOIN historical_player_match_stats AS stats ON stats.historical_match_id = hm.id + {where} + GROUP BY hm.id, hs.slug, hs.display_name + ORDER BY COALESCE(hm.ended_at, hm.started_at) DESC + LIMIT %s + """, + (*params, limit), + ).fetchall() + return [_recent_match_row(row) for row in rows] + + +def get_scoreboard_match_detail(*, server_slug: str, match_id: str) -> dict[str, object] | None: + initialize_postgres_display_storage() + with connect_postgres() as connection: + row = connection.execute( + """ + SELECT hm.id AS match_pk, hs.slug AS server_slug, hs.display_name AS server_name, + hm.external_match_id, hm.started_at, hm.ended_at, hm.map_pretty_name, + hm.map_name, hm.allied_score, hm.axis_score, hm.raw_payload_ref, + COUNT(stats.id) AS player_count, + SUM(COALESCE(stats.time_seconds, 0)) AS total_time_seconds + FROM historical_matches AS hm + JOIN historical_servers AS hs ON hs.id = hm.historical_server_id + LEFT JOIN historical_player_match_stats AS stats ON stats.historical_match_id = hm.id + WHERE hs.slug = %s AND hm.external_match_id = %s + GROUP BY hm.id, hs.slug, hs.display_name + LIMIT 1 + """, + (server_slug, match_id), + ).fetchone() + if not row: + return None + players = connection.execute( + """ + SELECT hp.display_name, hp.stable_player_key, hp.steam_id, stats.team_side, stats.level, + stats.kills, stats.deaths, stats.teamkills, stats.combat, stats.offense, + stats.defense, stats.support, stats.time_seconds + FROM historical_player_match_stats AS stats + JOIN historical_players AS hp ON hp.id = stats.historical_player_id + WHERE stats.historical_match_id = %s + ORDER BY COALESCE(stats.kills, 0) DESC, hp.display_name ASC + """, + (row["match_pk"],), + ).fetchall() + started_at = row["started_at"] + ended_at = row["ended_at"] + return { + "server": {"slug": row["server_slug"], "name": row["server_name"]}, + "match_id": row["external_match_id"], + "started_at": started_at, + "ended_at": ended_at, + "closed_at": ended_at or started_at, + "duration_seconds": _duration_seconds(started_at, ended_at), + "map": {"name": row["map_name"], "pretty_name": row["map_pretty_name"] or row["map_name"]}, + "result": _match_result(row["allied_score"], row["axis_score"]), + "player_count": int(row["player_count"] or 0), + "total_time_seconds": _int(row["total_time_seconds"]), + "players": [ + { + "name": player["display_name"], + "stable_player_key": player["stable_player_key"], + "team_side": player["team_side"], + **build_external_player_profile_fields(steam_id=player["steam_id"]), + **{ + key: _int(player[key]) + for key in ( + "level", "kills", "deaths", "teamkills", "combat", + "offense", "defense", "support", "time_seconds", + ) + }, + } + for player in players + ], + "capture_basis": "public-scoreboard-match", + "match_url": resolve_trusted_scoreboard_match_url(row["raw_payload_ref"], row["server_slug"]), + } + + +def list_scoreboard_server_summaries(*, server_slug: str | None) -> list[dict[str, object]]: + initialize_postgres_display_storage() + if server_slug == ALL_SERVERS_SLUG: + rows = list_scoreboard_server_summaries(server_slug=None) + return [_all_server_summary(rows)] + where = "WHERE hs.slug = %s" if server_slug else "" + params = (server_slug,) if server_slug else () + with connect_postgres() as connection: + rows = connection.execute( + f""" + SELECT hs.slug AS server_slug, hs.display_name AS server_name, + COUNT(DISTINCT hm.id) AS matches_count, + COUNT(DISTINCT hp.id) AS unique_players, + COALESCE(SUM(stats.kills), 0) AS total_kills, + COUNT(DISTINCT COALESCE(hm.map_pretty_name, hm.map_name)) AS map_count, + MIN(COALESCE(hm.ended_at, hm.started_at, hm.created_at_source)) AS first_match_at, + MAX(COALESCE(hm.ended_at, hm.started_at, hm.created_at_source)) AS last_match_at + FROM historical_servers AS hs + LEFT JOIN historical_matches AS hm ON hm.historical_server_id = hs.id + LEFT JOIN historical_player_match_stats AS stats ON stats.historical_match_id = hm.id + LEFT JOIN historical_players AS hp ON hp.id = stats.historical_player_id + {where} + GROUP BY hs.id + ORDER BY hs.server_number ASC, hs.slug ASC + """, + params, + ).fetchall() + map_rows = connection.execute( + f""" + SELECT hs.slug AS server_slug, + COALESCE(hm.map_pretty_name, hm.map_name, 'Mapa no disponible') AS map_name, + COUNT(*) AS matches_count + FROM historical_matches AS hm + JOIN historical_servers AS hs ON hs.id = hm.historical_server_id + {where} + GROUP BY hs.slug, COALESCE(hm.map_pretty_name, hm.map_name, 'Mapa no disponible') + ORDER BY hs.slug ASC, matches_count DESC, map_name ASC + """, + params, + ).fetchall() + maps: dict[str, list[dict[str, object]]] = {} + for row in map_rows: + maps.setdefault(str(row["server_slug"]), []) + if len(maps[str(row["server_slug"])]) < 3: + maps[str(row["server_slug"])].append( + {"map_name": row["map_name"], "matches_count": int(row["matches_count"] or 0)} + ) + return [_summary_row(row, maps.get(str(row["server_slug"]), [])) for row in rows] + + +def list_scoreboard_leaderboard( + *, timeframe: str, metric: str, server_id: str | None, limit: int +) -> dict[str, object]: + current = datetime.now(timezone.utc) + if timeframe == "monthly": + current_start = current.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + previous_start = (current_start - timedelta(days=1)).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + label = ("current-month", "Mes actual", "previous-closed-month-fallback", "Mes cerrado anterior") + else: + current_midnight = current.replace(hour=0, minute=0, second=0, microsecond=0) + current_start = current_midnight - timedelta(days=current_midnight.weekday()) + previous_start = current_start - timedelta(days=7) + label = ("current-week", "Semana actual", "previous-closed-week-fallback", "Semana cerrada anterior") + current_count = _count_scoreboard_matches(server_id, current_start, current) + previous_count = _count_scoreboard_matches(server_id, previous_start, current_start) + fallback = current_count <= 0 and previous_count > 0 + start, end = (previous_start, current_start) if fallback else (current_start, current) + rows = _leaderboard_rows(server_id=server_id, metric=metric, start=start, end=end, limit=limit) + window_days = max(1, int(((end - start).total_seconds() + 86399) // 86400)) + result = { + "metric": metric, + "window_start": _iso(start), + "window_end": _iso(end), + "window_days": window_days, + "window_kind": label[2] if fallback else label[0], + "window_label": label[3] if fallback else label[1], + "uses_fallback": fallback, + "selection_reason": ( + "no-current-month-matches" if fallback and timeframe == "monthly" + else "insufficient-current-week-sample" if fallback + else label[0] + ), + "items": rows, + } + if timeframe == "monthly": + result.update( + { + "timeframe": "monthly", + "current_month_start": _iso(current_start), + "current_month_closed_matches": current_count, + "previous_month_closed_matches": previous_count, + "sufficient_sample": { + "minimum_closed_matches": 1, + "current_month_closed_matches": current_count, + "current_month_has_sufficient_sample": current_count > 0, + "is_early_month": current.day <= 3, + }, + } + ) + else: + result.update( + { + "current_week_start": _iso(current_start), + "current_week_closed_matches": current_count, + "previous_week_closed_matches": previous_count, + "sufficient_sample": { + "minimum_closed_matches": 1, + "current_week_closed_matches": current_count, + "current_week_has_sufficient_sample": current_count > 0, + "is_early_week": current.weekday() <= get_historical_weekly_fallback_max_weekday(), + "fallback_max_weekday": get_historical_weekly_fallback_max_weekday(), + }, + } + ) + return result + + +def table_counts() -> dict[str, int]: + initialize_postgres_display_storage() + tables = ( + "historical_matches", + "historical_player_match_stats", + "displayed_historical_snapshots", + "player_event_raw_ledger", + "server_snapshots", + ) + with connect_postgres() as connection: + return { + table: int(connection.execute(f"SELECT COUNT(*) AS count FROM {table}").fetchone()["count"] or 0) + for table in tables + } + + +def _leaderboard_rows( + *, server_id: str | None, metric: str, start: datetime, end: datetime, limit: int +) -> list[dict[str, object]]: + metric_sql = { + "kills": "COALESCE(SUM(stats.kills), 0)", + "deaths": "COALESCE(SUM(stats.deaths), 0)", + "support": "COALESCE(SUM(stats.support), 0)", + "matches_over_100_kills": ( + "COALESCE(SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END), 0)" + ), + }[metric] + aggregate = server_id == ALL_SERVERS_SLUG + where, server_params = _server_where(server_id) + server_slug = f"'{ALL_SERVERS_SLUG}'" if aggregate else "hs.slug" + server_name = f"'{ALL_SERVERS_DISPLAY_NAME}'" if aggregate else "hs.display_name" + partition = f"'{ALL_SERVERS_SLUG}'" if aggregate else "hs.slug" + group_by = "hp.id" if aggregate else "hs.slug, hs.display_name, hp.id" + with connect_postgres() as connection: + rows = connection.execute( + f""" + WITH ranked AS ( + SELECT {server_slug} AS server_slug, {server_name} AS server_name, + hp.stable_player_key, hp.display_name AS player_name, hp.steam_id, + COUNT(DISTINCT hm.id) AS matches_count, {metric_sql} AS metric_value, + ROW_NUMBER() OVER ( + PARTITION BY {partition} + ORDER BY {metric_sql} DESC, COUNT(DISTINCT hm.id) ASC, hp.display_name ASC + ) AS ranking_position + FROM historical_player_match_stats AS stats + JOIN historical_matches AS hm ON hm.id = stats.historical_match_id + JOIN historical_servers AS hs ON hs.id = hm.historical_server_id + JOIN historical_players AS hp ON hp.id = stats.historical_player_id + WHERE hm.ended_at IS NOT NULL AND hm.ended_at >= %s AND hm.ended_at < %s {where} + GROUP BY {group_by} + ) + SELECT * FROM ranked WHERE ranking_position <= %s + ORDER BY server_slug ASC, ranking_position ASC + """, + (_iso(start), _iso(end), *server_params, limit), + ).fetchall() + return [ + { + "server": {"slug": row["server_slug"], "name": row["server_name"]}, + "time_range": {"start": _iso(start), "end": _iso(end), "window_days": max(1, (end - start).days or 1)}, + "player": { + "stable_player_key": row["stable_player_key"], + "name": row["player_name"], + "steam_id": row["steam_id"], + }, + "metric": metric, + "ranking_position": int(row["ranking_position"]), + "metric_value": int(row["metric_value"] or 0), + "matches_considered": int(row["matches_count"] or 0), + } + for row in rows + ] + + +def _count_scoreboard_matches(server_id: str | None, start: datetime, end: datetime) -> int: + where, server_params = _server_where(server_id) + with connect_postgres() as connection: + row = connection.execute( + f""" + SELECT COUNT(DISTINCT hm.id) AS count + FROM historical_matches AS hm + JOIN historical_servers AS hs ON hs.id = hm.historical_server_id + JOIN historical_player_match_stats AS stats ON stats.historical_match_id = hm.id + WHERE hm.ended_at IS NOT NULL AND hm.ended_at >= %s AND hm.ended_at < %s {where} + """, + (_iso(start), _iso(end), *server_params), + ).fetchone() + return int(row["count"] or 0) + + +def _server_where(server_id: str | None) -> tuple[str, tuple[object, ...]]: + if not server_id or server_id == ALL_SERVERS_SLUG: + return "", () + return "AND (hs.slug = %s OR CAST(hs.server_number AS TEXT) = %s)", (server_id, server_id) + + +def _recent_match_row(row: Mapping[str, object]) -> dict[str, object]: + return { + "server": {"slug": row["server_slug"], "name": row["server_name"]}, + "match_id": row["external_match_id"], + "started_at": row["started_at"], + "ended_at": row["ended_at"], + "closed_at": row["ended_at"] or row["started_at"], + "map": {"name": row["map_name"], "pretty_name": row["map_pretty_name"] or row["map_name"]}, + "result": _match_result(row["allied_score"], row["axis_score"]), + "player_count": int(row["player_count"] or 0), + "match_url": resolve_trusted_scoreboard_match_url(row["raw_payload_ref"], row["server_slug"]), + } + + +def _summary_row(row: Mapping[str, object], top_maps: list[dict[str, object]]) -> dict[str, object]: + first = row["first_match_at"] + last = row["last_match_at"] + matches = int(row["matches_count"] or 0) + return { + "server": {"slug": row["server_slug"], "name": row["server_name"]}, + "matches_count": matches, + "imported_matches_count": matches, + "unique_players": int(row["unique_players"] or 0), + "total_kills": int(row["total_kills"] or 0), + "map_count": int(row["map_count"] or 0), + "top_maps": top_maps, + "coverage": { + "basis": "postgres-migrated-public-scoreboard", + "status": "available" if matches else "empty", + "imported_matches_count": matches, + "discovered_total_matches": None, + "first_match_at": first, + "last_match_at": last, + "coverage_days": _coverage_days(first, last), + }, + "backfill": {}, + "time_range": {"start": first, "end": last}, + } + + +def _all_server_summary(items: list[dict[str, object]]) -> dict[str, object]: + starts = [item["time_range"]["start"] for item in items if item["time_range"]["start"]] + ends = [item["time_range"]["end"] for item in items if item["time_range"]["end"]] + return { + "server": {"slug": ALL_SERVERS_SLUG, "name": ALL_SERVERS_DISPLAY_NAME}, + "matches_count": sum(int(item["matches_count"]) for item in items), + "imported_matches_count": sum(int(item["imported_matches_count"]) for item in items), + "unique_players": None, + "total_kills": sum(int(item["total_kills"]) for item in items), + "map_count": None, + "top_maps": [], + "coverage": {"basis": "postgres-migrated-public-scoreboard", "status": "available" if items else "empty"}, + "backfill": {}, + "time_range": {"start": min(starts) if starts else None, "end": max(ends) if ends else None}, + } + + +def _attach_server_history(connection: Any, item: dict[str, object]) -> dict[str, object]: + rows = connection.execute( + """ + SELECT captured_at, status, players FROM server_snapshots + WHERE server_id = %s ORDER BY captured_at DESC LIMIT %s + """, + (item["server_id"], SUMMARY_SNAPSHOT_LIMIT), + ).fetchall() + players = [int(row["players"]) for row in rows if row["players"] is not None] + online = [row for row in rows if row["status"] == "online"] + item["history_summary"] = { + "window_size": SUMMARY_SNAPSHOT_LIMIT, + "recent_capture_count": len(rows), + "recent_online_count": len(online), + "recent_average_players": round(sum(players) / len(players), 1) if players else None, + "recent_peak_players": max(players, default=None), + "last_seen_online_at": online[0]["captured_at"] if online else None, + "minutes_since_last_capture": _minutes_since(rows[0]["captured_at"]) if rows else None, + } + return item + + +def _match_result(allied: object, axis: object) -> dict[str, object]: + allied_int, axis_int = _int(allied), _int(axis) + winner = None + if allied_int is not None and axis_int is not None: + winner = "allied" if allied_int > axis_int else "axis" if axis_int > allied_int else "draw" + return {"allied_score": allied_int, "axis_score": axis_int, "winner": winner} + + +def _duration_seconds(start: object, end: object) -> int | None: + start_point, end_point = _parse_datetime(_iso(start)), _parse_datetime(_iso(end)) + return max(0, int((end_point - start_point).total_seconds())) if start_point and end_point else None + + +def _coverage_days(start: object, end: object) -> int | None: + seconds = _duration_seconds(start, end) + return max(1, int((seconds + 86399) // 86400)) if seconds is not None else None + + +def _minutes_since(value: object) -> int | None: + point = _parse_datetime(_iso(value)) + return max(0, int((datetime.now(timezone.utc) - point).total_seconds() // 60)) if point else None + + +def _int(value: object) -> int | None: + try: + return None if value is None else int(value) + except (TypeError, ValueError): + return None + + +def _fallback_external_id(value: object) -> str: + normalized = "".join( + character.lower() if character.isalnum() else "-" + for character in str(value or "unknown-server") + ) + compact = "-".join(part for part in normalized.split("-") if part) + return compact or "unknown-server" + + +def _iso(value: object) -> str | None: + if isinstance(value, datetime): + point = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return point.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + text = str(value or "").strip() + 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 + try: + point = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return point.astimezone(timezone.utc) if point.tzinfo else point.replace(tzinfo=timezone.utc) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/backend/app/postgres_rcon_storage.py b/backend/app/postgres_rcon_storage.py new file mode 100644 index 0000000..4fad140 --- /dev/null +++ b/backend/app/postgres_rcon_storage.py @@ -0,0 +1,1038 @@ +"""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 upsert_scoreboard_candidate( + *, + server_slug: str, + candidate: Mapping[str, object], +) -> str: + """Persist one trusted scoreboard correlation candidate and report the upsert path.""" + external_match_id = str(candidate.get("external_match_id") or "").strip() + match_url = str(candidate.get("match_url") or "").strip() + if not external_match_id or not match_url: + return "skipped" + + initialize_postgres_rcon_storage() + with connect_postgres() as connection: + existing = connection.execute( + """ + SELECT id + FROM rcon_scoreboard_match_candidates + WHERE server_slug = %s AND external_match_id = %s + LIMIT 1 + """, + (server_slug, external_match_id), + ).fetchone() + 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, + external_match_id, + 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"), + match_url, + ), + ) + return "updated" if existing else "inserted" + + +def count_migrated_tables() -> dict[str, int]: + 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]]: + 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: + 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 691dae3..879983c 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,39 +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") - - 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)) + 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 caught_errors as error: + errors.append(str(error)) return { "matches_seen": matches_seen, "matches_materialized": matches_materialized, @@ -149,21 +195,41 @@ 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: + 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 * - 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, @@ -179,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 * @@ -190,6 +262,16 @@ def get_materialized_rcon_match_detail( """, (match_key, server_key, server_key), ).fetchone() + if match is None and match_key.startswith(f"{server_key}:"): + match = connection.execute( + """ + SELECT * + FROM rcon_materialized_matches + WHERE match_key = ? + LIMIT 1 + """, + (match_key,), + ).fetchone() if match is None: return None stat_rows = connection.execute( @@ -231,7 +313,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"] @@ -242,7 +330,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( @@ -261,11 +349,61 @@ 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 = ? + ) AS ranked_matches + 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], + "match_end_status": ( + "admin-log-match-end-events-available" + if latest_match_end_events + else "no-admin-log-match-end-events-stored" + ), } @@ -298,7 +436,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..b32dca2 100644 --- a/backend/app/rcon_admin_log_storage.py +++ b/backend/app/rcon_admin_log_storage.py @@ -5,18 +5,29 @@ from __future__ import annotations import json import re import sqlite3 +from collections import Counter 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 +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 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.""" + 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 +101,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 +292,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) @@ -300,6 +333,203 @@ def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dic return [dict(row) for row in rows] +def list_current_match_kill_feed( + *, + server_key: str, + limit: int = 30, + since_event_id: str | None = None, + 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) + since_row_id = _parse_current_match_event_row_id(since_event_id) + 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(sqlite3.connect(resolved_path)) + + with connection_scope as connection: + if isinstance(connection, sqlite3.Connection): + connection.row_factory = sqlite3.Row + boundary = connection.execute( + """ + SELECT event_type, server_time + FROM rcon_admin_log_events + WHERE (target_key = ? OR external_server_id = ?) + AND event_type IN ('match_start', 'match_end') + AND server_time IS NOT NULL + ORDER BY server_time DESC, id DESC + LIMIT 1 + """, + (server_key, server_key), + ).fetchone() + open_start_time = ( + boundary["server_time"] + if boundary is not None and boundary["event_type"] == "match_start" + else None + ) + if open_start_time is None: + 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: + 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" + + 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], + } + + +def list_current_match_player_stats( + *, + server_key: str, + db_path: Path | None = None, + now: datetime | None = None, +) -> dict[str, object]: + """Return partial current player stats derived from safe AdminLog kill rows.""" + feed = list_current_match_kill_feed( + server_key=server_key, + limit=100, + db_path=db_path, + now=now, + ) + players: dict[str, dict[str, object]] = {} + weapon_counts: dict[str, Counter[str]] = {} + for item in feed["items"]: + if not isinstance(item, Mapping): + continue + killer = _ensure_current_match_player( + players, + item.get("killer_name"), + team=item.get("killer_team"), + event_timestamp=item.get("event_timestamp"), + ) + victim = _ensure_current_match_player( + players, + item.get("victim_name"), + team=item.get("victim_team"), + event_timestamp=item.get("event_timestamp"), + ) + if killer is not None: + weapon = _safe_event_field(item.get("weapon")) or "UNKNOWN" + weapon_counts.setdefault(str(killer["player_name"]), Counter())[weapon] += 1 + if item.get("is_teamkill"): + killer["teamkills"] = int(killer["teamkills"]) + 1 + else: + killer["kills"] = int(killer["kills"]) + 1 + if victim is not None: + victim["deaths"] = int(victim["deaths"]) + 1 + if item.get("is_teamkill"): + victim["deaths_by_teamkill"] = int(victim["deaths_by_teamkill"]) + 1 + + items = [] + for player in players.values(): + items.append( + { + **player, + "favorite_weapon": _favorite_weapon_for_player( + weapon_counts.get(str(player["player_name"])) + ), + "source": "rcon-admin-log-kill-events", + "confidence": "event-derived-partial", + } + ) + items.sort( + key=lambda player: ( + -int(player["kills"]), + int(player["deaths"]), + str(player["player_name"]).casefold(), + ) + ) + return { + "scope": feed["scope"], + "confidence": "event-derived-partial" if items else feed["confidence"], + "source": "rcon-admin-log-kill-events", + "updated_at": max( + (str(item["last_seen_at"]) for item in items if item.get("last_seen_at")), + default=None, + ), + "stale_events_filtered": feed["stale_events_filtered"], + "items": items, + } + + def get_latest_rcon_player_profile_summaries( *, target_key: str, @@ -310,6 +540,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 +622,171 @@ def _json_mapping(raw_value: object) -> dict[str, object]: except json.JSONDecodeError: return {} return parsed if isinstance(parsed, dict) else {} + + +def _serialize_kill_feed_row(row: Mapping[str, object]) -> dict[str, object]: + payload = _json_mapping(row["parsed_payload_json"]) + target_key = str(row["external_server_id"] or row["target_key"] or "unknown") + killer_team = _safe_event_field(payload.get("killer_team")) + victim_team = _safe_event_field(payload.get("victim_team")) + return { + "event_id": f"rcon-admin-log:{target_key}:{row['id']}", + "event_timestamp": row["event_timestamp"], + "server_time": row["server_time"], + "killer_name": _safe_event_field(payload.get("killer_name")), + "killer_team": killer_team, + "victim_name": _safe_event_field(payload.get("victim_name")), + "victim_team": victim_team, + "weapon": _safe_event_field(payload.get("weapon")), + "is_teamkill": bool( + killer_team + and killer_team != "None" + and killer_team == victim_team + ), + } + + +def _parse_current_match_event_row_id(value: object) -> int | None: + prefix, separator, row_id = str(value or "").rpartition(":") + if separator != ":" or not prefix.startswith("rcon-admin-log:"): + return None + try: + parsed = int(row_id) + except ValueError: + return None + return parsed if parsed > 0 else None + + +def _safe_event_field(value: object) -> str | None: + normalized = str(value or "").strip() + return normalized or None + + +def _ensure_current_match_player( + players: dict[str, dict[str, object]], + player_name: object, + *, + team: object, + event_timestamp: object, +) -> dict[str, object] | None: + safe_name = _safe_event_field(player_name) + if safe_name is None: + return None + player = players.setdefault( + safe_name, + { + "player_name": safe_name, + "team": None, + "kills": 0, + "deaths": 0, + "teamkills": 0, + "deaths_by_teamkill": 0, + "last_seen_at": None, + }, + ) + safe_team = _safe_event_field(team) + if safe_team: + player["team"] = safe_team + safe_timestamp = _safe_event_field(event_timestamp) + if safe_timestamp and ( + player["last_seen_at"] is None or safe_timestamp > str(player["last_seen_at"]) + ): + player["last_seen_at"] = safe_timestamp + return player + + +def _favorite_weapon_for_player(weapons: Counter[str] | None) -> str | None: + if not weapons: + return None + return min(weapons.items(), key=lambda item: (-item[1], item[0]))[0] + + +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], + 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_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_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()) diff --git a/backend/app/rcon_historical_leaderboards.py b/backend/app/rcon_historical_leaderboards.py new file mode 100644 index 0000000..4da272e --- /dev/null +++ b/backend/app/rcon_historical_leaderboards.py @@ -0,0 +1,600 @@ +"""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 .config import get_historical_weekly_fallback_min_matches +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 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"), + "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"), + "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, + timeframe: str = "weekly", + 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. + + 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)) + 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, + metric=normalized_metric, + limit=normalized_limit, + 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": 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, + } + + +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 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, + *, + 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(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": [], + } + + +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 _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()) + 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 _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) + 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") diff --git a/backend/app/rcon_historical_read_model.py b/backend/app/rcon_historical_read_model.py index 697014b..9434ba7 100644 --- a/backend/app/rcon_historical_read_model.py +++ b/backend/app/rcon_historical_read_model.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone from .historical_storage import ALL_SERVERS_SLUG from .normalizers import normalize_map_name +from .player_external_profiles import build_external_player_profile_fields from .rcon_scoreboard_correlation import resolve_rcon_scoreboard_match_url from .rcon_historical_storage import ( find_rcon_historical_competitive_window, @@ -205,9 +206,9 @@ def get_rcon_historical_match_detail( 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) + scoreboard_correlation = build_materialized_scoreboard_correlation_input(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), @@ -245,13 +246,7 @@ def _build_materialized_recent_item(item: dict[str, object]) -> dict[str, object else SESSION_RESULT_SOURCE ), "match_url": resolve_rcon_scoreboard_match_url( - server_slug=server_slug, - map_name=item.get("map_pretty_name") or item.get("map_name"), - started_at=correlation_window["started_at"], - ended_at=correlation_window["ended_at"], - duration_seconds=_calculate_match_duration_seconds(item), - allied_score=item.get("allied_score"), - axis_score=item.get("axis_score"), + **scoreboard_correlation, ), "capabilities": describe_rcon_historical_read_model()["capabilities"], } @@ -273,6 +268,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 +276,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 +284,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], *, @@ -304,6 +313,7 @@ def _build_player_row( "top_weapons": _top_counter(row.get("weapons_json")), "most_killed": _top_counter(row.get("most_killed_json")), "death_by": _top_counter(row.get("death_by_json")), + **build_external_player_profile_fields(player_id=row.get("player_id")), } if profile_summary: player["profile_summary"] = profile_summary @@ -370,6 +380,23 @@ def _build_materialized_scoreboard_correlation_window( } +def build_materialized_scoreboard_correlation_input( + item: dict[str, object], +) -> dict[str, object]: + """Build safe candidate correlation inputs for one materialized RCON match.""" + timestamps = _build_materialized_timestamp_payload(item) + correlation_window = _build_materialized_scoreboard_correlation_window(item, timestamps) + return { + "server_slug": item.get("external_server_id") or item.get("target_key"), + "map_name": item.get("map_pretty_name") or item.get("map_name"), + "started_at": correlation_window["started_at"], + "ended_at": correlation_window["ended_at"], + "duration_seconds": _calculate_match_duration_seconds(item), + "allied_score": item.get("allied_score"), + "axis_score": item.get("axis_score"), + } + + def _merge_recent_items( primary_items: list[dict[str, object]], fallback_items: list[dict[str, object]], @@ -555,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) @@ -567,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/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_historical_worker.py b/backend/app/rcon_historical_worker.py index 940c169..d308e7a 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 @@ -16,6 +17,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 +46,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 +70,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 +132,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 +171,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 +186,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, }, } @@ -182,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)) + _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( @@ -229,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) @@ -256,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") @@ -434,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: diff --git a/backend/app/rcon_scoreboard_correlation.py b/backend/app/rcon_scoreboard_correlation.py index fd170eb..4465fca 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 @@ -30,12 +30,42 @@ def resolve_rcon_scoreboard_match_url( db_path: Path | None = None, ) -> str | None: """Return a trusted scoreboard URL for an RCON window only on strong evidence.""" + resolution = resolve_rcon_scoreboard_correlation( + server_slug=server_slug, + map_name=map_name, + started_at=started_at, + ended_at=ended_at, + duration_seconds=duration_seconds, + player_count=player_count, + peak_players=peak_players, + allied_score=allied_score, + axis_score=axis_score, + db_path=db_path, + ) + match_url = resolution.get("match_url") + return str(match_url) if match_url else None + + +def resolve_rcon_scoreboard_correlation( + *, + server_slug: object, + map_name: object, + started_at: object, + ended_at: object, + duration_seconds: object = None, + player_count: object = None, + peak_players: object = None, + allied_score: object = None, + axis_score: object = None, + db_path: Path | None = None, +) -> dict[str, object]: + """Return a safe candidate selection summary for one RCON match window.""" normalized_server_slug = str(server_slug or "").strip() normalized_map = normalize_map_name(map_name) rcon_start = _parse_timestamp(started_at) rcon_end = _parse_timestamp(ended_at) if not normalized_server_slug or not normalized_map or not rcon_start or not rcon_end: - return None + return {"match_url": None, "candidate_count": 0, "reason": "invalid-rcon-window"} if rcon_end < rcon_start: rcon_start, rcon_end = rcon_end, rcon_start @@ -60,15 +90,127 @@ def resolve_rcon_scoreboard_match_url( is not None ] if not scored_candidates: - return None + return { + "match_url": None, + "candidate_count": len(candidates), + "reason": "no-safe-candidate", + } scored_candidates.sort(key=lambda item: item["score"], reverse=True) best = scored_candidates[0] if int(best["score"]) < MIN_CONFIDENCE_SCORE: - return None + return { + "match_url": None, + "candidate_count": len(candidates), + "reason": "low-confidence", + } if len(scored_candidates) > 1 and int(scored_candidates[1]["score"]) >= int(best["score"]): - return None - return str(best["match_url"]) + return { + "match_url": None, + "candidate_count": len(candidates), + "reason": "ambiguous-candidate", + } + return { + "match_url": str(best["match_url"]), + "candidate_count": len(candidates), + "reason": "linked", + "selected_candidate": { + "external_match_id": best.get("external_match_id"), + "correlation_score": int(best["score"]), + }, + } + + +def diagnose_rcon_scoreboard_correlation( + *, + server_slug: object, + map_name: object, + started_at: object, + ended_at: object, + duration_seconds: object = None, + player_count: object = None, + peak_players: object = None, + allied_score: object = None, + axis_score: object = None, + db_path: Path | None = None, +) -> dict[str, object]: + """Describe safe candidate scoring for a single RCON correlation window.""" + normalized_server_slug = str(server_slug or "").strip() + normalized_map = normalize_map_name(map_name) + rcon_start = _parse_timestamp(started_at) + rcon_end = _parse_timestamp(ended_at) + if not normalized_server_slug or not normalized_map or not rcon_start or not rcon_end: + return { + "candidate_search_window": { + "started_at": started_at, + "ended_at": ended_at, + "candidate_limit": MAX_CANDIDATES, + }, + "candidate_count": 0, + "top_candidates": [], + "selected_candidate": None, + "final_reason": "invalid-rcon-window", + } + if rcon_end < rcon_start: + rcon_start, rcon_end = rcon_end, rcon_start + + candidates = _list_persisted_scoreboard_candidates( + server_slug=normalized_server_slug, + db_path=db_path or get_storage_path(), + ) + resolution = resolve_rcon_scoreboard_correlation( + server_slug=server_slug, + map_name=map_name, + started_at=started_at, + ended_at=ended_at, + duration_seconds=duration_seconds, + player_count=player_count, + peak_players=peak_players, + allied_score=allied_score, + axis_score=axis_score, + db_path=db_path, + ) + summaries = [ + _diagnostic_candidate_summary( + candidate, + server_slug=normalized_server_slug, + normalized_map=normalized_map, + rcon_start=rcon_start, + rcon_end=rcon_end, + duration_seconds=_coerce_int(duration_seconds), + player_count=_coerce_int(player_count), + peak_players=_coerce_int(peak_players), + allied_score=_coerce_int(allied_score), + axis_score=_coerce_int(axis_score), + ) + for candidate in candidates + ] + summaries.sort( + key=lambda item: ( + -int(item["correlation_score"] or -1), + str(item.get("external_match_id") or ""), + ) + ) + selected_id = ( + resolution.get("selected_candidate", {}).get("external_match_id") + if isinstance(resolution.get("selected_candidate"), dict) + else None + ) + selected_candidate = next( + (item for item in summaries if item.get("external_match_id") == selected_id), + None, + ) + return { + "candidate_search_window": { + "started_at": rcon_start.isoformat().replace("+00:00", "Z"), + "ended_at": rcon_end.isoformat().replace("+00:00", "Z"), + "candidate_limit": MAX_CANDIDATES, + }, + "candidate_count": len(candidates), + "top_candidates": summaries[:5], + "selected_candidate": selected_candidate, + "final_reason": resolution["reason"], + } def _list_persisted_scoreboard_candidates( @@ -76,6 +218,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 +280,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 @@ -207,10 +363,61 @@ def _score_candidate( return None return { "score": score, + "external_match_id": candidate.get("external_match_id"), "match_url": candidate["match_url"], } +def _diagnostic_candidate_summary( + candidate: dict[str, object], + *, + server_slug: str, + normalized_map: str, + rcon_start: datetime, + rcon_end: datetime, + duration_seconds: int | None, + player_count: int | None, + peak_players: int | None, + allied_score: int | None, + axis_score: int | None, +) -> dict[str, object]: + match_url = resolve_trusted_scoreboard_match_url(candidate.get("match_url"), server_slug) + safe_candidate = {**candidate, "match_url": match_url} if match_url else None + scored = ( + _score_candidate( + safe_candidate, + normalized_map=normalized_map, + rcon_start=rcon_start, + rcon_end=rcon_end, + duration_seconds=duration_seconds, + player_count=player_count, + peak_players=peak_players, + allied_score=allied_score, + axis_score=axis_score, + ) + if safe_candidate + else None + ) + map_label = candidate.get("map_pretty_name") or candidate.get("map_name") + summary = { + "external_match_id": candidate.get("external_match_id"), + "started_at": candidate.get("started_at"), + "ended_at": candidate.get("ended_at"), + "map": map_label, + "score": { + "allied_score": _coerce_int(candidate.get("allied_score")), + "axis_score": _coerce_int(candidate.get("axis_score")), + }, + "match_url": match_url, + "correlation_score": int(scored["score"]) if scored else None, + } + if not match_url: + summary["rejection_reason"] = "unsafe-url" + elif scored is None: + summary["rejection_reason"] = "map-or-window-mismatch" + return summary + + def _overlap_seconds( first_start: datetime, first_end: datetime, diff --git a/backend/app/rcon_scoreboard_relink.py b/backend/app/rcon_scoreboard_relink.py new file mode 100644 index 0000000..3835cb2 --- /dev/null +++ b/backend/app/rcon_scoreboard_relink.py @@ -0,0 +1,78 @@ +"""Report safe scoreboard links for existing materialized RCON matches.""" + +from __future__ import annotations + +import argparse +import json +from collections.abc import Iterable +from pathlib import Path + +from .rcon_admin_log_materialization import list_materialized_rcon_matches +from .rcon_historical_read_model import build_materialized_scoreboard_correlation_input +from .rcon_scoreboard_correlation import resolve_rcon_scoreboard_correlation + + +DEFAULT_LIMIT = 500 + + +def relink_materialized_matches( + *, + server_key: str | None = None, + limit: int = DEFAULT_LIMIT, + db_path: Path | None = None, +) -> dict[str, object]: + """Scan existing matches against trusted candidates used by the detail read model.""" + matches = list_materialized_rcon_matches( + target_key=server_key, + only_ended=True, + limit=limit, + db_path=db_path, + ) + report: dict[str, object] = { + "matches_scanned": len(matches), + "candidates_scanned": 0, + "matches_linked": 0, + "matches_skipped_no_candidate": 0, + "matches_skipped_ambiguous": 0, + "errors": [], + } + for match in matches: + try: + resolution = resolve_rcon_scoreboard_correlation( + **build_materialized_scoreboard_correlation_input(match), + db_path=db_path, + ) + except Exception as exc: + report["errors"].append( + {"match_key": match.get("match_key"), "message": str(exc)} + ) + continue + report["candidates_scanned"] += int(resolution.get("candidate_count") or 0) + if resolution.get("match_url"): + report["matches_linked"] += 1 + elif resolution.get("reason") == "ambiguous-candidate": + report["matches_skipped_ambiguous"] += 1 + else: + report["matches_skipped_no_candidate"] += 1 + return report + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Resolve trusted scoreboard links for materialized RCON matches." + ) + parser.add_argument("--server", dest="server_key") + parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT) + parser.add_argument("--db-path", type=Path, default=None) + args = parser.parse_args(list(argv) if argv is not None else None) + report = relink_materialized_matches( + server_key=args.server_key, + limit=max(1, args.limit), + db_path=args.db_path, + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 if not report["errors"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/routes.py b/backend/app/routes.py index 607ffac..c906413 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -5,8 +5,12 @@ 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, + build_current_match_player_stats_payload, + build_current_match_payload, build_discord_payload, build_elo_mmr_leaderboard_payload, build_elo_mmr_player_payload, @@ -37,6 +41,8 @@ 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 GET_ROUTES = { @@ -60,6 +66,38 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object] return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") return HTTPStatus.OK, build_server_history_payload(limit=limit) + if parsed.path == "/api/current-match": + server_slug = parse_qs(parsed.query).get("server", [None])[0] + if not server_slug: + return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required") + if get_trusted_public_scoreboard_origin(server_slug) is None: + return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported") + return HTTPStatus.OK, build_current_match_payload(server_slug=server_slug) + + if parsed.path == "/api/current-match/kills": + limit = _parse_limit(parsed.query) + if limit is None: + return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter") + params = parse_qs(parsed.query) + server_slug = params.get("server", [None])[0] + if not server_slug: + return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required") + if get_trusted_public_scoreboard_origin(server_slug) is None: + return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported") + return HTTPStatus.OK, build_current_match_kill_feed_payload( + server_slug=server_slug, + limit=limit, + since_event_id=params.get("since_event_id", [None])[0], + ) + + if parsed.path == "/api/current-match/players": + server_slug = parse_qs(parsed.query).get("server", [None])[0] + if not server_slug: + return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required") + if get_trusted_public_scoreboard_origin(server_slug) is None: + return HTTPStatus.NOT_FOUND, build_error_payload("Current match server is not supported") + return HTTPStatus.OK, build_current_match_player_stats_payload(server_slug=server_slug) + if parsed.path == "/api/historical/weekly-top-kills": limit = _parse_limit(parsed.query) if limit is None: @@ -163,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, @@ -179,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, @@ -229,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, diff --git a/backend/app/scoreboard_candidate_backfill.py b/backend/app/scoreboard_candidate_backfill.py index 03b72ff..5db8a08 100644 --- a/backend/app/scoreboard_candidate_backfill.py +++ b/backend/app/scoreboard_candidate_backfill.py @@ -5,11 +5,17 @@ from __future__ import annotations import argparse import json from datetime import datetime, timezone +from collections.abc import Mapping from typing import Iterable from .historical_storage import initialize_historical_storage, list_historical_servers, upsert_historical_match +from .postgres_rcon_storage import upsert_scoreboard_candidate from .providers.public_scoreboard_provider import PublicScoreboardHistoricalDataSource -from .scoreboard_origins import list_trusted_public_scoreboard_origins +from .scoreboard_origins import ( + build_trusted_scoreboard_match_url, + get_trusted_public_scoreboard_origin, + list_trusted_public_scoreboard_origins, +) DEFAULT_MAX_PAGES = 20 DEFAULT_PAGE_SIZE = 100 @@ -34,8 +40,19 @@ def run_backfill(*, server: dict[str, object], start_at: datetime, end_at: datet provider = PublicScoreboardHistoricalDataSource() server_slug = str(server["slug"]) base_url = str(server["scoreboard_base_url"]) - counters = {"pages_processed": 0, "candidates_seen": 0, "candidates_inserted": 0, "candidates_updated": 0, "player_rows_inserted": 0, "player_rows_updated": 0} + counters = { + "pages_processed": 0, + "candidates_seen": 0, + "list_candidates_inserted": 0, + "list_candidates_updated": 0, + "list_candidates_skipped": 0, + "candidates_inserted": 0, + "candidates_updated": 0, + "player_rows_inserted": 0, + "player_rows_updated": 0, + } errors: list[dict[str, object]] = [] + skipped_unsafe_urls = 0 stopped_after_window = False for page in range(1, max_pages + 1): try: @@ -56,6 +73,27 @@ def run_backfill(*, server: dict[str, object], start_at: datetime, end_at: datet continue if ref_time and ref_time >= end_at: continue + candidate = _build_list_candidate(server=server, match=match) + if candidate is None: + counters["list_candidates_skipped"] += 1 + skipped_unsafe_urls += int(_list_candidate_url_is_unsafe(server=server, match=match)) + else: + try: + outcome = upsert_scoreboard_candidate( + server_slug=server_slug, + candidate=candidate, + ) + except Exception as exc: + counters["list_candidates_skipped"] += 1 + errors.append( + { + "stage": "upsert_list_scoreboard_candidate", + "match_id": candidate["external_match_id"], + "message": str(exc), + } + ) + else: + counters[f"list_candidates_{outcome}"] += 1 match_id = _stringify(match.get("id")) if match_id: ids.append(match_id) @@ -77,7 +115,7 @@ def run_backfill(*, server: dict[str, object], start_at: datetime, end_at: datet counters["player_rows_updated"] += _coerce_int(delta.get("player_rows_updated")) if stopped_after_window: break - return {"status": "ok" if not errors else "partial", "server": server_slug, "scoreboard_base_url": base_url, "requested_window": {"from": _format_timestamp(start_at), "to": _format_timestamp(end_at)}, "stopped_after_window": stopped_after_window, "skipped_unsafe_urls": 0, "errors": errors, **counters} + return {"status": "ok" if not errors else "partial", "server": server_slug, "scoreboard_base_url": base_url, "requested_window": {"from": _format_timestamp(start_at), "to": _format_timestamp(end_at)}, "stopped_after_window": stopped_after_window, "skipped_unsafe_urls": skipped_unsafe_urls, "errors": errors, **counters} def build_arg_parser() -> argparse.ArgumentParser: @@ -137,6 +175,65 @@ def _pick_match_timestamp(match: dict[str, object]) -> object: return None +def _build_list_candidate( + *, + server: Mapping[str, object], + match: Mapping[str, object], +) -> dict[str, object] | None: + server_slug = _stringify(server.get("slug")) + external_match_id = _stringify(match.get("id")) + origin = get_trusted_public_scoreboard_origin(server_slug) + map_payload = match.get("map") + result_payload = match.get("result") + if ( + origin is None + or not external_match_id + or not external_match_id.isdigit() + or str(server.get("scoreboard_base_url") or "").strip() != origin.base_url + or _coerce_optional_int(server.get("server_number")) != origin.server_number + or _coerce_optional_int(match.get("server_number")) != origin.server_number + or not isinstance(map_payload, Mapping) + or not isinstance(result_payload, Mapping) + ): + return None + + started_at = _stringify(match.get("start")) + ended_at = _stringify(match.get("end")) + match_url = build_trusted_scoreboard_match_url( + server_slug=server_slug, + external_match_id=external_match_id, + ) + if not started_at or not ended_at or not match_url: + return None + return { + "external_match_id": external_match_id, + "started_at": started_at, + "ended_at": ended_at, + "map_name": _stringify(map_payload.get("id") or map_payload.get("name")), + "map_pretty_name": _stringify(map_payload.get("pretty_name")), + "allied_score": _coerce_optional_int(result_payload.get("allied")), + "axis_score": _coerce_optional_int(result_payload.get("axis")), + "player_count": _coerce_optional_int(match.get("player_count")), + "match_url": match_url, + } + + +def _list_candidate_url_is_unsafe( + *, + server: Mapping[str, object], + match: Mapping[str, object], +) -> bool: + external_match_id = _stringify(match.get("id")) + return bool( + external_match_id + and build_trusted_scoreboard_match_url( + server_slug=server.get("slug"), + external_match_id=external_match_id, + ) + is None + ) + + def _stringify(value: object) -> str | None: if value is None: return None @@ -151,5 +248,12 @@ def _coerce_int(value: object) -> int: return 0 +def _coerce_optional_int(value: object) -> int | None: + try: + return None if value is None else int(value) + except (TypeError, ValueError): + return None + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/backend/app/scoreboard_correlation_diagnostics.py b/backend/app/scoreboard_correlation_diagnostics.py new file mode 100644 index 0000000..0b88d8f --- /dev/null +++ b/backend/app/scoreboard_correlation_diagnostics.py @@ -0,0 +1,83 @@ +"""JSON diagnostics for missing materialized RCON scoreboard links.""" + +from __future__ import annotations + +import argparse +import json +from collections.abc import Iterable +from pathlib import Path + +from .rcon_admin_log_materialization import get_materialized_rcon_match_detail +from .rcon_historical_read_model import build_materialized_scoreboard_correlation_input +from .rcon_scoreboard_correlation import diagnose_rcon_scoreboard_correlation + + +def inspect_materialized_match_correlation( + *, + server_key: str, + match_key: str, + db_path: Path | None = None, +) -> dict[str, object]: + """Return safe scoreboard correlation diagnostics for one materialized match.""" + materialized = get_materialized_rcon_match_detail( + server_key=server_key, + match_key=match_key, + db_path=db_path, + ) + if materialized is None: + return { + "rcon_match_key": match_key, + "server": server_key, + "candidate_count": 0, + "top_candidates": [], + "selected_candidate": None, + "final_reason": "rcon-match-not-found", + } + + match = materialized["match"] + correlation_input = build_materialized_scoreboard_correlation_input(match) + correlation = diagnose_rcon_scoreboard_correlation( + **correlation_input, + db_path=db_path, + ) + return { + "rcon_match_key": match.get("match_key"), + "server": match.get("external_server_id") or match.get("target_key"), + "map": match.get("map_pretty_name") or match.get("map_name"), + "started_at": match.get("started_at"), + "ended_at": match.get("ended_at"), + "closed_at": match.get("ended_at") or match.get("started_at"), + "duration_seconds": correlation_input.get("duration_seconds"), + "score": { + "allied_score": match.get("allied_score"), + "axis_score": match.get("axis_score"), + "winner": match.get("winner"), + }, + **correlation, + } + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Explain scoreboard candidate correlation for one RCON match." + ) + parser.add_argument("--server", required=True) + parser.add_argument("--match", dest="match_key", required=True) + parser.add_argument("--db-path", type=Path, default=None) + args = parser.parse_args(list(argv) if argv is not None else None) + print( + json.dumps( + inspect_materialized_match_correlation( + server_key=args.server, + match_key=args.match_key, + db_path=args.db_path, + ), + ensure_ascii=False, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/scoreboard_origins.py b/backend/app/scoreboard_origins.py index a58854e..1aaf1b3 100644 --- a/backend/app/scoreboard_origins.py +++ b/backend/app/scoreboard_origins.py @@ -79,3 +79,19 @@ def resolve_trusted_scoreboard_match_url( if candidate_parts.params or candidate_parts.query or candidate_parts.fragment: return None return candidate + + +def build_trusted_scoreboard_match_url( + *, + server_slug: object, + external_match_id: object, +) -> str | None: + """Build a trusted scoreboard match URL from one numeric public match id.""" + origin = get_trusted_public_scoreboard_origin(server_slug) + match_id = str(external_match_id or "").strip() + if origin is None or not match_id.isdigit(): + return None + return resolve_trusted_scoreboard_match_url( + f"{origin.base_url}/games/{match_id}", + origin.slug, + ) diff --git a/backend/app/sqlite_to_postgres_migration.py b/backend/app/sqlite_to_postgres_migration.py new file mode 100644 index 0000000..a28d102 --- /dev/null +++ b/backend/app/sqlite_to_postgres_migration.py @@ -0,0 +1,368 @@ +"""Idempotent phase-2 migration from displayed SQLite/files into PostgreSQL.""" + +from __future__ import annotations + +import json +import sqlite3 +from collections import defaultdict +from contextlib import closing +from pathlib import Path +from typing import Any + +from .config import get_storage_path +from .postgres_display_storage import ( + connect_postgres as connect_display_postgres, + initialize_postgres_display_storage, + persist_snapshot_record, +) +from .postgres_rcon_storage import initialize_postgres_rcon_storage + + +RCON_TABLES = ( + "rcon_historical_targets", + "rcon_historical_capture_runs", + "rcon_historical_samples", + "rcon_historical_checkpoints", + "rcon_historical_competitive_windows", + "rcon_admin_log_events", + "rcon_player_profile_snapshots", + "rcon_materialized_matches", + "rcon_match_player_stats", + "rcon_scoreboard_match_candidates", +) +DISPLAY_TABLES = ( + "game_sources", + "servers", + "server_snapshots", + "historical_servers", + "historical_maps", + "historical_matches", + "historical_players", + "historical_player_match_stats", + "player_event_raw_ledger", +) +SKIP_SLUG = "comunidad-hispana-03" + + +def migrate_sqlite_to_postgres() -> dict[str, object]: + """Copy displayed legacy data to PostgreSQL without deleting legacy sources.""" + initialize_postgres_rcon_storage() + initialize_postgres_display_storage() + summary: dict[str, object] = { + "status": "ok", + "source_paths": [], + "migrated_tables": [], + "migrated_domains": [], + "rows_read": {}, + "rows_inserted": {}, + "rows_updated": {}, + "rows_skipped": {}, + "errors": [], + } + table_totals: dict[str, dict[str, int]] = defaultdict( + lambda: {"read": 0, "inserted": 0, "updated": 0, "skipped": 0} + ) + for db_path in _discover_sqlite_paths(): + summary["source_paths"].append(str(db_path)) + try: + _migrate_sqlite_path(db_path, table_totals) + except Exception as error: # noqa: BLE001 - report all source failures + summary["errors"].append({"source_path": str(db_path), "error": str(error)}) + + snapshots_root = get_storage_path().parent / "snapshots" + if snapshots_root.exists(): + summary["source_paths"].append(str(snapshots_root)) + _migrate_snapshot_files(snapshots_root, table_totals, summary["errors"]) + _sync_sequences() + summary["migrated_tables"] = sorted(table_totals) + summary["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", + "public-scoreboard-historical-matches-and-player-stats", + "weekly-and-monthly-scoreboard-rankings", + "displayed-historical-snapshots", + "live-server-summary-cache", + "player-event-ledger", + ] + for table_name, totals in sorted(table_totals.items()): + summary["rows_read"][table_name] = totals["read"] + summary["rows_inserted"][table_name] = totals["inserted"] + summary["rows_updated"][table_name] = totals["updated"] + summary["rows_skipped"][table_name] = totals["skipped"] + summary["status"] = "ok" if not summary["errors"] else "completed-with-errors" + return summary + + +def _migrate_sqlite_path(db_path: Path, totals: dict[str, dict[str, int]]) -> None: + with closing(sqlite3.connect(db_path)) as sqlite_connection: + sqlite_connection.row_factory = sqlite3.Row + available_tables = { + row["name"] + for row in sqlite_connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + tables = [table for table in (*RCON_TABLES, *DISPLAY_TABLES) if table in available_tables] + with connect_display_postgres() as postgres_connection: + postgres_columns = { + table: _postgres_columns(postgres_connection, table) + for table in tables + } + historical_server_ids = _legacy_server03_ids(sqlite_connection) + historical_match_ids = _legacy_match_ids(sqlite_connection, historical_server_ids) + legacy_rcon_target_ids = _legacy_rcon_target03_ids(sqlite_connection) + for table_name in tables: + _copy_table( + sqlite_connection, + postgres_connection, + table_name=table_name, + postgres_columns=postgres_columns[table_name], + totals=totals[table_name], + historical_server_ids=historical_server_ids, + historical_match_ids=historical_match_ids, + legacy_rcon_target_ids=legacy_rcon_target_ids, + ) + + +def _copy_table( + sqlite_connection: sqlite3.Connection, + postgres_connection: Any, + *, + table_name: str, + postgres_columns: list[str], + totals: dict[str, int], + historical_server_ids: set[int], + historical_match_ids: set[int], + legacy_rcon_target_ids: set[int], +) -> None: + sqlite_columns = [ + str(row["name"]) + for row in sqlite_connection.execute(f"PRAGMA table_info({table_name})").fetchall() + ] + columns = [column for column in sqlite_columns if column in postgres_columns] + if not columns: + return + rows = sqlite_connection.execute( + f"SELECT {', '.join(columns)} FROM {table_name}" + ).fetchall() + placeholders = ", ".join(["%s"] * len(columns)) + sql = ( + f"INSERT INTO {table_name} ({', '.join(columns)}) " + f"VALUES ({placeholders}) ON CONFLICT DO NOTHING" + ) + values: list[tuple[object, ...]] = [] + for row in rows: + totals["read"] += 1 + row_dict = dict(row) + if _skip_row( + table_name, + row_dict, + historical_server_ids=historical_server_ids, + historical_match_ids=historical_match_ids, + legacy_rcon_target_ids=legacy_rcon_target_ids, + ): + totals["skipped"] += 1 + continue + values.append(tuple(_postgres_value(column, row_dict[column]) for column in columns)) + with postgres_connection.cursor() as cursor: + for start in range(0, len(values), 1000): + batch = values[start : start + 1000] + cursor.executemany(sql, batch) + inserted = max(0, int(cursor.rowcount or 0)) + totals["inserted"] += inserted + totals["skipped"] += len(batch) - inserted + + +def _migrate_snapshot_files( + snapshots_root: Path, + totals: dict[str, dict[str, int]], + errors: list[object], +) -> None: + snapshot_totals = totals["displayed_historical_snapshots"] + for snapshot_path in sorted(snapshots_root.glob("*/*.json")): + snapshot_totals["read"] += 1 + try: + document = json.loads(snapshot_path.read_text(encoding="utf-8")) + if str(document.get("server_key") or "") == SKIP_SLUG: + snapshot_totals["skipped"] += 1 + continue + before = _snapshot_exists(document) + persist_snapshot_record(document) + snapshot_totals["updated" if before else "inserted"] += 1 + except Exception as error: # noqa: BLE001 - keep migrating neighboring snapshots + snapshot_totals["skipped"] += 1 + errors.append({"source_path": str(snapshot_path), "error": str(error)}) + + +def _snapshot_exists(document: dict[str, object]) -> bool: + with connect_display_postgres() as connection: + row = connection.execute( + """ + SELECT 1 FROM displayed_historical_snapshots + WHERE server_key = %s AND snapshot_type = %s AND metric = %s AND snapshot_window = %s + """, + ( + str(document.get("server_key") or ""), + str(document.get("snapshot_type") or ""), + str(document.get("metric") or ""), + str(document.get("window") or ""), + ), + ).fetchone() + return bool(row) + + +def _skip_row( + table_name: str, + row: dict[str, object], + *, + historical_server_ids: set[int], + historical_match_ids: set[int], + legacy_rcon_target_ids: set[int], +) -> bool: + if row.get("server_slug") == SKIP_SLUG or row.get("slug") == SKIP_SLUG: + return True + if row.get("external_server_id") == SKIP_SLUG or row.get("target_key") == SKIP_SLUG: + return True + if table_name == "historical_matches" and row.get("historical_server_id") in historical_server_ids: + return True + if ( + table_name == "historical_player_match_stats" + and row.get("historical_match_id") in historical_match_ids + ): + return True + if table_name == "rcon_historical_samples" and row.get("target_id") in legacy_rcon_target_ids: + return True + if table_name == "rcon_historical_checkpoints" and row.get("target_id") in legacy_rcon_target_ids: + return True + if table_name == "rcon_historical_competitive_windows" and row.get("target_id") in legacy_rcon_target_ids: + return True + return False + + +def _legacy_server03_ids(connection: sqlite3.Connection) -> set[int]: + if not _has_table(connection, "historical_servers"): + return set() + return { + int(row["id"]) + for row in connection.execute( + "SELECT id FROM historical_servers WHERE slug = ?", + (SKIP_SLUG,), + ).fetchall() + } + + +def _legacy_rcon_target03_ids(connection: sqlite3.Connection) -> set[int]: + if not _has_table(connection, "rcon_historical_targets"): + return set() + return { + int(row["id"]) + for row in connection.execute( + """ + SELECT id FROM rcon_historical_targets + WHERE external_server_id = ? OR target_key = ? + """, + (SKIP_SLUG, SKIP_SLUG), + ).fetchall() + } + + +def _legacy_match_ids(connection: sqlite3.Connection, historical_server_ids: set[int]) -> set[int]: + if not historical_server_ids or not _has_table(connection, "historical_matches"): + return set() + placeholders = ", ".join(["?"] * len(historical_server_ids)) + return { + int(row["id"]) + for row in connection.execute( + f"SELECT id FROM historical_matches WHERE historical_server_id IN ({placeholders})", + tuple(sorted(historical_server_ids)), + ).fetchall() + } + + +def _postgres_columns(connection: Any, table_name: str) -> list[str]: + rows = connection.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = %s + ORDER BY ordinal_position + """, + (table_name,), + ).fetchall() + return [str(row["column_name"]) for row in rows] + + +def _sync_sequences() -> None: + tables = ( + "game_sources", + "servers", + "server_snapshots", + "historical_servers", + "historical_maps", + "historical_matches", + "historical_players", + "historical_player_match_stats", + "player_event_raw_ledger", + "rcon_historical_targets", + "rcon_historical_capture_runs", + "rcon_historical_samples", + "rcon_historical_competitive_windows", + "rcon_admin_log_events", + "rcon_player_profile_snapshots", + "rcon_materialized_matches", + "rcon_match_player_stats", + "rcon_scoreboard_match_candidates", + ) + with connect_display_postgres() as connection: + for table_name in tables: + connection.execute( + f""" + SELECT setval( + pg_get_serial_sequence(%s, 'id'), + GREATEST(COALESCE((SELECT MAX(id) FROM {table_name}), 1), 1), + TRUE + ) + """, + (table_name,), + ) + + +def _discover_sqlite_paths() -> list[Path]: + configured = get_storage_path() + candidates = {configured} + if configured.parent.exists(): + candidates.update(configured.parent.glob("*.sqlite*")) + return sorted( + path + for path in candidates + if path.exists() + and path.is_file() + and not str(path).endswith(("-shm", "-wal")) + ) + + +def _has_table(connection: sqlite3.Connection, table_name: str) -> bool: + return bool( + connection.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", + (table_name,), + ).fetchone() + ) + + +def _postgres_value(column: str, value: object) -> object: + if column in {"is_active", "is_teamkill"}: + return bool(value) + return value + + +def main() -> None: + print(json.dumps(migrate_sqlite_to_postgres(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/backend/app/storage.py b/backend/app/storage.py index e99dd4e..e64c4ad 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -7,7 +7,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Iterable, Mapping -from .config import get_storage_path +from .config import get_storage_path, use_postgres_rcon_storage from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer @@ -90,10 +90,19 @@ def persist_snapshot_batch( db_path: Path | None = None, ) -> dict[str, object]: """Persist a batch of normalized snapshots into local SQLite storage.""" - resolved_path = initialize_storage(db_path=db_path) source_definition = dict(DEFAULT_GAME_SOURCE) if game_source is not None: source_definition.update(game_source) + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import persist_server_snapshots + + return persist_server_snapshots( + snapshots, + source_name=source_name, + captured_at=captured_at, + game_source=source_definition, + ) + resolved_path = initialize_storage(db_path=db_path) persisted = 0 with _connect(resolved_path) as connection: @@ -145,6 +154,10 @@ def persist_snapshot_batch( def list_latest_snapshots(*, db_path: Path | None = None) -> list[dict[str, object]]: """Return the latest persisted snapshot for each known server.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_latest_server_snapshots + + return list_latest_server_snapshots() resolved_path = resolve_storage_path(db_path=db_path) if not resolved_path.exists(): return [] @@ -190,6 +203,10 @@ def list_snapshot_history( limit: int = 20, ) -> list[dict[str, object]]: """Return recent persisted snapshots across all servers.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_server_snapshot_history + + return list_server_snapshot_history(limit=limit) resolved_path = resolve_storage_path(db_path=db_path) if not resolved_path.exists(): return [] @@ -230,6 +247,10 @@ def list_server_history( limit: int = 20, ) -> list[dict[str, object]]: """Return recent history for one server by numeric id or external id.""" + if use_postgres_rcon_storage(explicit_sqlite_path=db_path): + from .postgres_display_storage import list_server_snapshot_history + + return list_server_snapshot_history(server_id=server_id, limit=limit) resolved_path = resolve_storage_path(db_path=db_path) if not resolved_path.exists(): return [] diff --git a/backend/app/storage_diagnostics.py b/backend/app/storage_diagnostics.py new file mode 100644 index 0000000..1ee8042 --- /dev/null +++ b/backend/app/storage_diagnostics.py @@ -0,0 +1,164 @@ +"""Report active PostgreSQL/displayed storage backend and migration parity 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_RCON_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 + from .postgres_display_storage import table_counts + + rcon_counts = count_migrated_tables() + displayed_counts = table_counts() + backend = "postgresql" + else: + rcon_counts = _count_sqlite_tables() + displayed_counts = {} + 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", + "public-scoreboard-historical-matches-and-player-stats", + "weekly-rankings", + "monthly-rankings", + "displayed-historical-snapshots", + "server-summary-and-live-server-cache", + "player-event-ledger", + ], + "table_counts": { + **rcon_counts, + **displayed_counts, + "admin_log_events": rcon_counts.get("rcon_admin_log_events", 0), + "materialized_matches": rcon_counts.get("rcon_materialized_matches", 0), + "player_stats": rcon_counts.get("rcon_match_player_stats", 0), + "public_scoreboard_historical_matches": displayed_counts.get( + "historical_matches", 0 + ), + "weekly_rankings_source_stats": displayed_counts.get( + "historical_player_match_stats", 0 + ), + "monthly_rankings_source_stats": displayed_counts.get( + "historical_player_match_stats", 0 + ), + "server_summary_cache": displayed_counts.get("displayed_historical_snapshots", 0), + "player_event_ledger": displayed_counts.get("player_event_raw_ledger", 0), + "scoreboard_candidates": rcon_counts.get("rcon_scoreboard_match_candidates", 0), + }, + "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"], + "remaining_sqlite_or_file_backed_domains": [ + { + "domain": "public-scoreboard ingestion run and backfill checkpoints", + "displayed_in_frontend": False, + "reason": "operational import bookkeeping is not read by visible pages", + "planned_phase": "phase-3-or-when-scoreboard-import-runs-on-postgresql", + }, + { + "domain": "Elo/MMR tables", + "displayed_in_frontend": False, + "reason": "Elo/MMR remains paused and hidden from visible pages", + "planned_phase": "phase-3", + }, + ], + "sqlite_remaining": [ + "public-scoreboard ingestion run and backfill checkpoints", + "paused Elo/MMR tables", + ], + "scoreboard_correlation": "PostgreSQL safe candidates and migrated trusted historical match URLs are used.", + "external_player_ids": _postgres_external_player_id_diagnostics() + if backend == "postgresql" + else { + "available_in_postgresql": False, + "reason": "PostgreSQL storage is not active.", + }, + "migration_parity_summary": { + "available": backend == "postgresql", + "source_command": "python -m app.sqlite_to_postgres_migration", + "displayed_historical_storage": ( + "postgresql" if backend == "postgresql" else "sqlite-or-file-fallback" + ), + }, + } + + +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_RCON_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 _postgres_external_player_id_diagnostics() -> dict[str, object]: + from .postgres_rcon_storage import connect_postgres + + with connect_postgres() as connection: + row = connection.execute( + """ + SELECT + (SELECT COUNT(*) FROM rcon_match_player_stats + WHERE player_id ~ '^[0-9]{17}$') AS rcon_match_steam_id64_rows, + (SELECT COUNT(*) FROM rcon_player_profile_snapshots + WHERE player_id ~ '^[0-9]{17}$') AS rcon_profile_steam_id64_rows, + (SELECT COUNT(*) FROM historical_players + WHERE steam_id ~ '^[0-9]{17}$') AS scoreboard_player_steam_id64_rows + """ + ).fetchone() + return { + "available_in_postgresql": True, + "rcon_match_steam_id64_rows": int(row["rcon_match_steam_id64_rows"] or 0), + "rcon_profile_steam_id64_rows": int(row["rcon_profile_steam_id64_rows"] or 0), + "scoreboard_player_steam_id64_rows": int( + row["scoreboard_player_steam_id64_rows"] or 0 + ), + } + + +def main() -> None: + print(json.dumps(build_storage_diagnostics(), ensure_ascii=False, indent=2, default=str)) + + +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/backend/tests/test_current_match_payload.py b/backend/tests/test_current_match_payload.py new file mode 100644 index 0000000..6ad00c3 --- /dev/null +++ b/backend/tests/test_current_match_payload.py @@ -0,0 +1,323 @@ +from http import HTTPStatus +from datetime import datetime, timezone +from unittest.mock import patch + +from app.payloads import build_current_match_payload +from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries +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_payload_fallback_resolves_legacy_rcon_external_id_for_01(): + data = _build_with_snapshot_fallback( + "comunidad-hispana-01", + { + "external_server_id": "rcon:152.114.195.174:7779", + "server_name": "#01 [ESP] Comunidad Hispana", + "status": "online", + "current_map": "St. Marie Du Mont", + "players": 0, + "max_players": 100, + "captured_at": "2026-03-24T14:08:41.008487Z", + }, + ) + + assert data["found"] is True + assert data["map"] == "St. Marie Du Mont" + assert data["map_pretty_name"] == "St. Marie Du Mont" + assert data["status"] == "online" + assert data["players"] == 0 + assert data["max_players"] == 100 + assert data["captured_at"] == "2026-03-24T14:08:41.008487Z" + assert data["updated_at"] == "2026-03-24T14:08:41.008487Z" + assert data["public_scoreboard_url"] == "https://scoreboard.comunidadhll.es" + + +def test_current_match_payload_fallback_resolves_legacy_rcon_source_ref_for_02(): + data = _build_with_snapshot_fallback( + "comunidad-hispana-02", + { + "external_server_id": "snapshot-server-02", + "source_ref": "rcon://152.114.195.150:7879", + "status": "online", + "current_map": "Elsenborn Ridge", + "captured_at": "2026-03-24T14:08:41.008487Z", + }, + ) + + assert data["found"] is True + assert data["server_slug"] == "comunidad-hispana-02" + assert data["map"] == "Elsenborn Ridge" + assert data["map_pretty_name"] == "Elsenborn Ridge" + assert data["public_scoreboard_url"] == "https://scoreboard.comunidadhll.es:5443" + + +def test_current_match_payload_fallback_resolves_community_server_names(): + number_first = _build_with_snapshot_fallback( + "comunidad-hispana-01", + { + "external_server_id": "snapshot-server-01", + "server_name": "#01 [ESP] Comunidad Hispana - Spa Onl", + "current_map": "Mortain", + }, + ) + community_first = _build_with_snapshot_fallback( + "comunidad-hispana-02", + { + "external_server_id": "snapshot-server-02", + "name": "Comunidad Hispana #02", + "current_map": "Carentan", + }, + ) + + assert number_first["found"] is True + assert number_first["map"] == "Mortain" + assert community_first["found"] is True + assert community_first["map"] == "Carentan" + + +def test_current_match_payload_fallback_does_not_match_unknown_snapshot(): + data = _build_with_snapshot_fallback( + "comunidad-hispana-01", + { + "external_server_id": "rcon:203.0.113.10:9000", + "source_ref": "rcon://203.0.113.10:9000", + "server_name": "#03 Comunidad Hispana", + "current_map": "Unknown Match", + }, + ) + + assert data["found"] is False + assert data["map"] is None + assert data["status"] == "unavailable" + + +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 test_current_match_player_route_rejects_unsupported_server(): + status, payload = resolve_get_payload("/api/current-match/players?server=not-trusted") + + assert status == HTTPStatus.NOT_FOUND + assert payload["status"] == "error" + + +def test_current_match_player_stats_aggregate_safe_admin_log_rows(tmp_path): + db_path = tmp_path / "admin-log.sqlite3" + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + }, + 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: Bravo(Axis/steam-bravo) -> " + "Alpha(Allies/steam-alpha) with MP40" + ), + }, + { + "timestamp": "2026-05-21T10:02:00Z", + "message": ( + "[3:00 min (140)] KILL: Alpha(Allies/steam-alpha) -> " + "Charlie(Allies/steam-charlie) with M1 GARAND" + ), + }, + { + "timestamp": "2026-05-21T10:03:00Z", + "message": ( + "[4:00 min (160)] KILL: Alpha(Allies/steam-alpha) -> " + "Bravo(Axis/steam-bravo) with M1 GARAND" + ), + }, + ], + db_path=db_path, + ) + + stats = list_current_match_player_stats( + server_key="comunidad-hispana-01", + db_path=db_path, + ) + + assert stats["scope"] == "open-admin-log-match-window" + assert stats["confidence"] == "event-derived-partial" + assert stats["source"] == "rcon-admin-log-kill-events" + assert [item["player_name"] for item in stats["items"]] == ["Alpha", "Bravo", "Charlie"] + assert stats["items"][0] == { + "player_name": "Alpha", + "team": "Allies", + "kills": 1, + "deaths": 1, + "teamkills": 1, + "deaths_by_teamkill": 0, + "last_seen_at": "2026-05-21T10:03:00Z", + "favorite_weapon": "M1 GARAND", + "source": "rcon-admin-log-kill-events", + "confidence": "event-derived-partial", + } + assert "raw_message" not in stats["items"][0] + + +def test_current_match_player_stats_filter_stale_recent_events(tmp_path): + db_path = tmp_path / "admin-log.sqlite3" + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-01", + "external_server_id": "comunidad-hispana-01", + }, + 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, + ) + + stats = list_current_match_player_stats( + server_key="comunidad-hispana-01", + db_path=db_path, + now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc), + ) + + assert stats["scope"] == "no-current-match-events" + assert stats["confidence"] == "stale-filtered" + assert stats["items"] == [] + + +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"] + + +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"] 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() diff --git a/backend/tests/test_json_serialization.py b/backend/tests/test_json_serialization.py new file mode 100644 index 0000000..be237e3 --- /dev/null +++ b/backend/tests/test_json_serialization.py @@ -0,0 +1,27 @@ +"""Regression coverage for API JSON encoding of PostgreSQL value types.""" + +from __future__ import annotations + +import json +import unittest +from datetime import date, datetime, timezone + +from app.main import _json_default + + +class JsonSerializationTests(unittest.TestCase): + def test_json_default_serializes_postgres_datetime_and_date_values(self) -> None: + payload = { + "started_at": datetime(2026, 5, 21, 10, 11, 12, tzinfo=timezone.utc), + "day": date(2026, 5, 21), + } + + encoded = json.loads(json.dumps(payload, default=_json_default)) + + self.assertEqual( + encoded, + { + "started_at": "2026-05-21T10:11:12+00:00", + "day": "2026-05-21", + }, + ) diff --git a/backend/tests/test_rcon_admin_log_storage.py b/backend/tests/test_rcon_admin_log_storage.py index b59ff91..7e62b4e 100644 --- a/backend/tests/test_rcon_admin_log_storage.py +++ b/backend/tests/test_rcon_admin_log_storage.py @@ -1,9 +1,12 @@ 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, + list_current_match_kill_feed, list_rcon_admin_log_event_counts, persist_rcon_admin_log_entries, ) @@ -259,3 +262,236 @@ def test_list_rcon_admin_log_event_counts_groups_by_target_and_event_type(tmp_pa }, } gc.collect() + + +def test_current_match_kill_feed_prefers_open_match_window_and_normalizes_rows(tmp_path): + db_path = tmp_path / "admin_log.sqlite3" + persist_rcon_admin_log_entries( + target=TARGET, + entries=[ + { + "timestamp": "2026-05-19T09:59:00Z", + "message": ( + "[0:59 min (90)] KILL: Old Killer(Allies/steam-old) -> " + "Old Victim(Axis/steam-victim-old) with M1 GARAND" + ), + }, + { + "timestamp": "2026-05-19T10:00:00Z", + "message": "[1:00 min (100)] MATCH START Mortain Warfare", + }, + { + "timestamp": "2026-05-19T10:01:00Z", + "message": ( + "[2:00 min (120)] KILL: Alpha(Allies/steam-alpha) -> " + "Bravo(Allies/steam-bravo) with GRENADE" + ), + }, + ], + db_path=db_path, + ) + + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + ) + + assert feed["scope"] == "open-admin-log-match-window" + assert feed["confidence"] == "admin-log-boundary" + assert len(feed["items"]) == 1 + assert feed["items"][0] == { + "event_id": "rcon-admin-log:test-rcon-target:3", + "event_timestamp": "2026-05-19T10:01:00Z", + "server_time": 120, + "killer_name": "Alpha", + "killer_team": "Allies", + "victim_name": "Bravo", + "victim_team": "Allies", + "weapon": "GRENADE", + "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() + + +def test_current_match_kill_feed_filters_rows_before_incremental_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, + ) + + feed = list_current_match_kill_feed( + server_key="test-rcon-target", + db_path=db_path, + since_event_id="rcon-admin-log:test-rcon-target:2", + ) + + 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() 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/backend/tests/test_rcon_materialization_pipeline.py b/backend/tests/test_rcon_materialization_pipeline.py index 9ec1143..6e1b9cf 100644 --- a/backend/tests/test_rcon_materialization_pipeline.py +++ b/backend/tests/test_rcon_materialization_pipeline.py @@ -74,8 +74,18 @@ class RconMaterializationPipelineTests(unittest.TestCase): self.assertEqual(detail["result_source"], "admin-log-match-ended") self.assertEqual(detail["result"]["allied_score"], 5) self.assertEqual(detail["timestamp_confidence"], "absolute") - self.assertNotIn("player_id", detail["players"][0]) - self.assertIn("kd_ratio", detail["players"][0]) + players = {row["player_name"]: row for row in detail["players"]} + self.assertNotIn("player_id", players["Alpha"]) + self.assertIn("kd_ratio", players["Alpha"]) + self.assertEqual(players["Alpha"]["steam_id_64"], "76561198000000001") + self.assertEqual(players["Alpha"]["platform"], "steam") + self.assertEqual( + players["Alpha"]["external_profile_links"]["hellor"], + "https://hellor.pro/player/76561198000000001", + ) + self.assertEqual(players["Charlie"]["platform"], "unknown") + self.assertNotIn("steam_id_64", players["Charlie"]) + self.assertNotIn("external_profile_links", players["Charlie"]) gc.collect() def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(self) -> None: @@ -250,6 +260,29 @@ class RconMaterializationPipelineTests(unittest.TestCase): self.assertNotEqual(payload["data"]["selected_source"], "public-scoreboard") gc.collect() + def test_recent_materialized_detail_id_resolves_through_detail_read_model(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_admin_log_fixture(db_path) + materialize_rcon_admin_log(db_path=db_path) + recent = list_rcon_historical_recent_activity( + server_key="comunidad-hispana-01", + limit=1, + )[0] + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-01", + match_id=str(recent["internal_detail_match_id"]), + ) + finally: + _restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path) + + self.assertIsNotNone(detail) + self.assertEqual(detail["match_id"], recent["internal_detail_match_id"]) + gc.collect() + def test_public_scoreboard_fallback_used_only_without_rcon_activity(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "historical.sqlite3" diff --git a/backend/tests/test_scoreboard_match_links.py b/backend/tests/test_scoreboard_match_links.py index da68f44..2d30d9a 100644 --- a/backend/tests/test_scoreboard_match_links.py +++ b/backend/tests/test_scoreboard_match_links.py @@ -8,7 +8,9 @@ import sqlite3 import tempfile import unittest from pathlib import Path +from unittest.mock import patch +from app.scoreboard_candidate_backfill import run_backfill from app.historical_storage import ( get_historical_match_detail, initialize_historical_storage, @@ -19,9 +21,80 @@ from app.rcon_historical_storage import initialize_rcon_historical_storage from app.rcon_historical_storage import persist_rcon_historical_sample from app.rcon_historical_storage import start_rcon_historical_capture_run from app.rcon_historical_read_model import get_rcon_historical_match_detail +from app.rcon_admin_log_materialization import materialize_rcon_admin_log +from app.rcon_admin_log_storage import persist_rcon_admin_log_entries +from app.rcon_scoreboard_relink import relink_materialized_matches +from app.scoreboard_correlation_diagnostics import inspect_materialized_match_correlation class PersistedScoreboardMatchLinkTests(unittest.TestCase): + def test_list_backfill_persists_foy_candidate_before_detail_fetch_failure(self) -> None: + stored: dict[tuple[str, str], dict[str, object]] = {} + + class FoyListProvider: + def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]: + return {"maps": [_foy_list_match()]} if page == 1 else {"maps": []} + + def fetch_match_details( + self, + *, + base_url: str, + match_ids: list[str], + max_workers: int, + ) -> list[dict[str, object]]: + raise RuntimeError("detail endpoint unavailable") + + def fake_upsert(*, server_slug: str, candidate: dict[str, object]) -> str: + key = (server_slug, str(candidate["external_match_id"])) + outcome = "updated" if key in stored else "inserted" + stored[key] = dict(candidate) + return outcome + + server = { + "slug": "comunidad-hispana-02", + "scoreboard_base_url": "https://scoreboard.comunidadhll.es:5443", + "server_number": 2, + } + with ( + patch("app.scoreboard_candidate_backfill.initialize_historical_storage"), + patch( + "app.scoreboard_candidate_backfill.PublicScoreboardHistoricalDataSource", + return_value=FoyListProvider(), + ), + patch( + "app.scoreboard_candidate_backfill.upsert_scoreboard_candidate", + side_effect=fake_upsert, + ), + ): + first = run_backfill( + server=server, + start_at=_backfill_timestamp("2026-05-20T00:00:00Z"), + end_at=_backfill_timestamp("2026-05-21T23:59:59Z"), + max_pages=2, + page_size=100, + detail_workers=1, + ) + second = run_backfill( + server=server, + start_at=_backfill_timestamp("2026-05-20T00:00:00Z"), + end_at=_backfill_timestamp("2026-05-21T23:59:59Z"), + max_pages=2, + page_size=100, + detail_workers=1, + ) + + candidate = stored[("comunidad-hispana-02", "1562115")] + self.assertEqual( + candidate["match_url"], + "https://scoreboard.comunidadhll.es:5443/games/1562115", + ) + self.assertEqual(first["list_candidates_inserted"], 1) + self.assertEqual(first["list_candidates_updated"], 0) + self.assertEqual(first["errors"][0]["stage"], "fetch_match_details") + self.assertEqual(second["list_candidates_inserted"], 0) + self.assertEqual(second["list_candidates_updated"], 1) + self.assertEqual(len(stored), 1) + def test_recent_and_detail_payloads_expose_safe_persisted_match_url(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "historical.sqlite3" @@ -70,6 +143,40 @@ class PersistedScoreboardMatchLinkTests(unittest.TestCase): self.assertIsNone(detail["match_url"]) gc.collect() + def test_detail_player_links_use_trusted_scoreboard_steam_id(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + _persist_match( + db_path, + server_slug="comunidad-hispana-02", + match_id="steam-player-match", + player_stats=[ + { + "player": "Steam Player", + "steaminfo": {"profile": {"steamid": "76561198000000009"}}, + "team": {"side": "allies"}, + "kills": 4, + "deaths": 2, + } + ], + ) + + detail = get_historical_match_detail( + server_slug="comunidad-hispana-02", + match_id="steam-player-match", + db_path=db_path, + ) + + self.assertIsNotNone(detail) + player = detail["players"][0] + self.assertEqual(player["steam_id_64"], "76561198000000009") + self.assertEqual(player["platform"], "steam") + self.assertEqual( + player["external_profile_links"]["hll_records"], + "https://hllrecords.com/profiles/76561198000000009", + ) + gc.collect() + def test_rcon_match_detail_does_not_fabricate_external_scoreboard_url(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "historical.sqlite3" @@ -167,6 +274,70 @@ class PersistedScoreboardMatchLinkTests(unittest.TestCase): self.assertIsNone(detail["match_url"]) gc.collect() + def test_foy_relink_reports_existing_materialized_match_url(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "historical.sqlite3" + previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH") + os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path) + try: + _persist_match( + db_path, + server_slug="comunidad-hispana-02", + match_id="1562115", + map_name="Foy Warfare", + started_at="2026-05-20T20:54:11Z", + ended_at="2026-05-20T22:24:11Z", + ) + persist_rcon_admin_log_entries( + target={ + "target_key": "comunidad-hispana-02", + "external_server_id": "comunidad-hispana-02", + }, + entries=[ + { + "timestamp": "2026-05-20T20:54:11Z", + "message": "[1 min (1779310451)] MATCH START Foy Warfare", + }, + { + "timestamp": "2026-05-20T22:24:11Z", + "message": "[91 min (1779315851)] MATCH ENDED `Foy Warfare` ALLIED (4 - 1) AXIS", + }, + ], + db_path=db_path, + ) + materialize_rcon_admin_log(db_path=db_path) + report = relink_materialized_matches( + server_key="comunidad-hispana-02", + db_path=db_path, + ) + detail = get_rcon_historical_match_detail( + server_key="comunidad-hispana-02", + match_id="comunidad-hispana-02:1779310451:1779315851:foywarfare", + ) + diagnostics = inspect_materialized_match_correlation( + server_key="comunidad-hispana-02", + match_key="comunidad-hispana-02:1779310451:1779315851:foywarfare", + db_path=db_path, + ) + finally: + if previous_storage_path is None: + os.environ.pop("HLL_BACKEND_STORAGE_PATH", None) + else: + os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path + + self.assertEqual(report["matches_scanned"], 1) + self.assertEqual(report["matches_linked"], 1) + self.assertGreaterEqual(report["candidates_scanned"], 1) + self.assertIsNotNone(detail) + self.assertEqual( + detail["match_url"], + "https://scoreboard.comunidadhll.es:5443/games/1562115", + ) + self.assertEqual(diagnostics["final_reason"], "linked") + self.assertEqual(diagnostics["selected_candidate"]["external_match_id"], "1562115") + self.assertEqual(diagnostics["top_candidates"][0]["map"], "Foy Warfare") + gc.collect() + def _persist_match( db_path: Path, @@ -176,6 +347,7 @@ def _persist_match( map_name: str = "carentan", started_at: str = "2026-05-01T10:00:00Z", ended_at: str = "2026-05-01T11:20:00Z", + player_stats: list[dict[str, object]] | None = None, ) -> None: upsert_historical_match( server_slug=server_slug, @@ -186,12 +358,29 @@ def _persist_match( "end": ended_at, "map": {"name": map_name}, "result": {"allied": 3, "axis": 2}, - "player_stats": [], + "player_stats": player_stats or [], }, db_path=db_path, ) +def _foy_list_match() -> dict[str, object]: + return { + "id": 1562115, + "server_number": 2, + "start": "2026-05-20T20:54:11+00:00", + "end": "2026-05-20T22:24:11+00:00", + "map": {"id": "foywarfare", "pretty_name": "Foy Warfare"}, + "result": {"allied": 4, "axis": 1}, + } + + +def _backfill_timestamp(raw_value: str): + from app.scoreboard_candidate_backfill import _parse_timestamp + + return _parse_timestamp(raw_value, option_name="test") + + def _persist_rcon_window( db_path: Path, *, diff --git a/docker-compose.yml b/docker-compose.yml index 4ff58cd..557c60c 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} @@ -56,8 +83,12 @@ 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: - - backend + postgres: + condition: service_healthy + backend: + condition: service_started volumes: - ./backend/data:/app/data restart: unless-stopped @@ -71,3 +102,6 @@ services: ports: - "8080:8080" restart: unless-stopped + +volumes: + postgres-data: diff --git a/docs/decisions.md b/docs/decisions.md index e93b019..293f7d6 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -177,3 +177,66 @@ 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`. + +## Decision 018: PostgreSQL phase 2 for displayed historical data + +PostgreSQL pasa a ser la fuente de lectura para los datos historicos visibles: + +- fallback publico `historical_*` de partidas, detalle y rankings +- snapshots historicos precalculados que consume `historico.html` +- cache live de servidores que consume `/api/servers` +- ledger player-event usado para reconstruir snapshots visibles +- tablas RCON de AdminLog, perfiles, ventanas, partidas materializadas, + estadisticas y candidatos seguros ya migradas en phase 1 + +La migracion se ejecuta de forma idempotente con: + +```powershell +cd backend +python -m app.sqlite_to_postgres_migration +python -m app.storage_diagnostics +``` + +El comando conserva IDs y `external_match_id` del scoreboard publico, claves +`match_key` materializadas y URLs seguras existentes. Copia SQLite y los JSON +historicos de `backend/data/snapshots` como fuentes legacy; no los vuelve a +usar como read model visible cuando `HLL_BACKEND_DATABASE_URL` esta definido. +Las filas legacy de `comunidad-hispana-03` se omiten en el read model visible +de esta migracion para no reactivar ese target. + +Permanecen fuera de phase 2: + +- checkpoints y runs operativos del import publico que no aparecen en frontend +- Elo/MMR pausado y oculto en la UI actual + +`app.storage_diagnostics` muestra conteos PostgreSQL, ultimas partidas +materializadas, ultimos `match_end`, dominios restantes y un resumen de paridad +para verificar la migracion antes de retirar fuentes legacy. 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. diff --git a/docs/scoreboard-correlation-debugging.md b/docs/scoreboard-correlation-debugging.md new file mode 100644 index 0000000..0fee09e --- /dev/null +++ b/docs/scoreboard-correlation-debugging.md @@ -0,0 +1,45 @@ +# Scoreboard Correlation Debugging + +Use backend commands to debug a missing public scoreboard button on an RCON +historical match. Normal frontend payloads and pages should stay free of +correlation diagnostics. + +## Sequence + +1. Refresh trusted public scoreboard candidates for the relevant server: + + ```powershell + docker compose exec backend python -m app.scoreboard_candidate_backfill --server comunidad-hispana-02 --from 2026-05-20T00:00:00Z --to 2026-05-21T23:59:59Z --max-pages 5 --page-size 100 + ``` + +2. Scan existing materialized RCON matches against those candidates: + + ```powershell + docker compose exec backend python -m app.rcon_scoreboard_relink --server comunidad-hispana-02 + ``` + +3. Inspect one match correlation: + + ```powershell + docker compose exec backend python -m app.scoreboard_correlation_diagnostics --server comunidad-hispana-02 --match comunidad-hispana-02:1779310451:1779315851:foywarfare + ``` + +4. Verify the detail endpoint used by the match page: + + ```powershell + Invoke-WebRequest 'http://localhost:8000/api/historical/matches/detail?server=comunidad-hispana-02&match=comunidad-hispana-02%3A1779310451%3A1779315851%3Afoywarfare' | Select-Object -ExpandProperty Content + ``` + +## Reading Output + +The diagnostic JSON includes the RCON match window, score, candidate search +window, safe top candidate summaries, the selected candidate when one is strong +enough, and `final_reason`. + +- `linked` means the detail read model can expose the trusted `match_url`. +- `no-safe-candidate` means candidate persistence or map/window matching needs + inspection. +- `low-confidence` means candidates exist but evidence is insufficient. +- `ambiguous-candidate` means two candidates tie and no public URL is selected. +- `unsafe-url` in a candidate summary means the raw candidate URL is not emitted + or selected. diff --git a/frontend/assets/css/historico-scoreboard-detail.css b/frontend/assets/css/historico-scoreboard-detail.css index 0015199..180074f 100644 --- a/frontend/assets/css/historico-scoreboard-detail.css +++ b/frontend/assets/css/historico-scoreboard-detail.css @@ -10,6 +10,407 @@ 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-table--players { + min-width: 760px; +} + +.historical-player-controls { + display: grid; + grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(150px, 190px)); + gap: 10px; + align-items: end; +} + +.historical-player-controls[hidden] { + display: none; +} + +.historical-player-control { + display: grid; + gap: 6px; + min-width: 0; +} + +.historical-player-control span { + color: var(--muted); + font-size: 0.66rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.historical-player-control input, +.historical-player-control select { + width: 100%; + min-height: 42px; + padding: 0 12px; + border: 1px solid rgba(159, 168, 141, 0.28); + border-radius: 8px; + background: rgba(10, 13, 10, 0.82); + color: var(--text); + font: inherit; +} + +.historical-player-control input::placeholder { + color: var(--muted); +} + +.historical-player-control input:focus-visible, +.historical-player-control select:focus-visible { + outline: 2px solid rgba(210, 182, 118, 0.72); + outline-offset: 1px; +} + +.historical-player-row { + outline: 0; +} + +.historical-player-row.is-inactive { + color: var(--muted); +} + +.historical-player-row.is-inactive .historical-player-row__details-button span:first-child { + color: var(--text-soft); + font-style: italic; +} + +.historical-player-row:hover td, +.historical-player-row:focus-within td, +.historical-player-row.is-expanded td { + background: rgba(210, 182, 118, 0.06); +} + +.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-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__profiles { + padding: 13px; +} + +.historical-player-profile-links { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.historical-player-profile-links a { + display: inline-flex; + min-height: 32px; + align-items: center; + padding: 0 11px; + border: 1px solid rgba(210, 182, 118, 0.34); + border-radius: 999px; + color: var(--accent-warm); + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.08em; + text-decoration: none; + text-transform: uppercase; +} + +.historical-player-profile-links a:hover, +.historical-player-profile-links a:focus-visible { + border-color: rgba(210, 182, 118, 0.62); + color: var(--text); +} + +.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; +} + +.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); +} + +@media (max-width: 760px) { + .historical-player-controls { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .historical-player-control--search { + grid-column: 1 / -1; + } + + .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; @@ -189,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/css/historico.css b/frontend/assets/css/historico.css index ddd380d..a3b08ba 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; } @@ -548,6 +589,304 @@ 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%; + min-height: 180px; + max-height: 520px; + padding: 10px; + overflow: hidden; + border: 1px solid rgba(159, 168, 141, 0.2); + border-radius: 6px; + background: + 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); +} + +.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; + 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(86px, 112px) minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 54px; + padding: 6px 9px; + overflow: hidden; + 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: rgba(19, 22, 22, 0.78); + animation: current-match-killfeed-enter 180ms ease-out; +} + +.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(210, 182, 118, 0.08), transparent 46%), + rgba(24, 25, 22, 0.92); +} + +.current-match-killfeed__row.is-teamkill { + border-left-color: rgba(210, 182, 118, 0.64); + background: + linear-gradient(90deg, rgba(210, 182, 118, 0.12), transparent 48%), + rgba(73, 49, 27, 0.32); +} + +.current-match-killfeed__player { + display: grid; + align-content: center; + gap: 3px; + min-width: 0; + 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(--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; + align-items: center; + justify-content: center; + gap: 1px; + min-width: 92px; + min-height: 38px; + padding: 2px 5px; + border: 1px solid rgba(159, 168, 141, 0.18); + border-radius: 3px; + color: var(--text); + background: rgba(8, 10, 11, 0.74); +} + +.current-match-killfeed__weapon-icon { + display: block; + 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: 24px; + height: 24px; + justify-self: center; + place-items: center; + border: 1px solid rgba(210, 182, 118, 0.3); + border-radius: 3px; + color: var(--accent-warm); + font-size: 0.82rem; + font-weight: 800; +} + +.current-match-killfeed__weapon-fallback[hidden] { + display: none; +} + +.current-match-killfeed__weapon em { + display: block; + max-width: 112px; + overflow: hidden; + color: var(--muted); + font-size: 0.58rem; + font-style: normal; + line-height: 1.1; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.current-match-killfeed__team-badge, +.current-match-killfeed__teamkill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 18px; + padding: 0 5px; + border-radius: 4px; + 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-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: 0; + 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; + transform: translateX(8px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + .historical-comparison-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); @@ -733,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; } @@ -754,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%; } @@ -765,6 +1119,49 @@ .historical-table { min-width: 540px; } + + .current-match-killfeed { + grid-template-columns: 1fr; + max-height: 500px; + } + + .current-match-killfeed__row { + grid-template-columns: minmax(0, 1fr) minmax(86px, 116px) minmax(0, 1fr); + } +} + +@media (max-width: 480px) { + .current-match-killfeed__row { + grid-template-columns: minmax(0, 1fr) 82px minmax(0, 1fr); + gap: 5px; + padding-inline: 6px; + } + + .current-match-killfeed__weapon { + min-width: 82px; + padding-inline: 3px; + } + + .current-match-killfeed__player-name { + 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; + } +} + +@media (prefers-reduced-motion: reduce) { + .current-match-killfeed__row { + animation: none; + } } .historical-match-card--clean { @@ -776,22 +1173,49 @@ } .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) + 340px; 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; + min-width: 340px; } .historical-match-card__actions-cell .historical-match-card__actions { + gap: 10px; align-items: center; 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)); @@ -805,50 +1229,15 @@ .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; -} - -.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 { + #recent-matches-list .historical-match-card__actions { + flex-wrap: wrap; justify-content: flex-start; } } /* 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/css/styles.css b/frontend/assets/css/styles.css index 6833dc1..a3699f2 100644 --- a/frontend/assets/css/styles.css +++ b/frontend/assets/css/styles.css @@ -617,7 +617,7 @@ h2 { } .server-card__top--stats { - align-items: stretch; + align-items: flex-start; gap: 18px; } @@ -669,8 +669,11 @@ h2 { .server-card__actions { margin: 0; - width: 100%; display: flex; + align-items: flex-end; + align-self: end; + flex-wrap: wrap; + gap: 8px; justify-content: flex-end; } @@ -678,7 +681,7 @@ h2 { display: inline-flex; align-items: center; justify-content: center; - width: 100%; + width: auto; max-width: 100%; min-height: 42px; min-width: 136px; @@ -740,6 +743,14 @@ h2 { gap: 12px; } +.server-card__bottom { + display: grid; + grid-template-columns: minmax(0, 280px) minmax(272px, 1fr); + align-items: end; + justify-content: space-between; + gap: 16px; +} + .server-card__quickfact { min-width: 0; padding: 12px 14px; @@ -1017,13 +1028,17 @@ h2 { } .server-card__actions { - justify-content: start; + justify-content: flex-start; } .server-card__quickfacts { grid-template-columns: 1fr; } + .server-card__bottom { + grid-template-columns: 1fr; + } + .clan-card__brand { grid-template-columns: 1fr; justify-items: start; diff --git a/frontend/assets/img/weapons/bazooka.PNG b/frontend/assets/img/weapons/bazooka.PNG new file mode 100644 index 0000000..1931bb2 Binary files /dev/null and b/frontend/assets/img/weapons/bazooka.PNG differ diff --git a/frontend/assets/img/weapons/bren_gun.PNG b/frontend/assets/img/weapons/bren_gun.PNG new file mode 100644 index 0000000..2ad8652 Binary files /dev/null and b/frontend/assets/img/weapons/bren_gun.PNG differ diff --git a/frontend/assets/img/weapons/browing_m1919.PNG b/frontend/assets/img/weapons/browing_m1919.PNG new file mode 100644 index 0000000..b35bbe2 Binary files /dev/null and b/frontend/assets/img/weapons/browing_m1919.PNG differ diff --git a/frontend/assets/img/weapons/colt_1911.PNG b/frontend/assets/img/weapons/colt_1911.PNG new file mode 100644 index 0000000..a303b9f Binary files /dev/null and b/frontend/assets/img/weapons/colt_1911.PNG differ diff --git a/frontend/assets/img/weapons/dp27.PNG b/frontend/assets/img/weapons/dp27.PNG new file mode 100644 index 0000000..eba5e9a Binary files /dev/null and b/frontend/assets/img/weapons/dp27.PNG differ diff --git a/frontend/assets/img/weapons/flammenwefer41.PNG b/frontend/assets/img/weapons/flammenwefer41.PNG new file mode 100644 index 0000000..316316d Binary files /dev/null and b/frontend/assets/img/weapons/flammenwefer41.PNG differ diff --git a/frontend/assets/img/weapons/gewehr.PNG b/frontend/assets/img/weapons/gewehr.PNG new file mode 100644 index 0000000..d4a32ed Binary files /dev/null and b/frontend/assets/img/weapons/gewehr.PNG differ diff --git a/frontend/assets/img/weapons/kar98k.PNG b/frontend/assets/img/weapons/kar98k.PNG new file mode 100644 index 0000000..01e3b2b Binary files /dev/null and b/frontend/assets/img/weapons/kar98k.PNG differ diff --git a/frontend/assets/img/weapons/kar98k_x8.PNG b/frontend/assets/img/weapons/kar98k_x8.PNG new file mode 100644 index 0000000..a951486 Binary files /dev/null and b/frontend/assets/img/weapons/kar98k_x8.PNG differ diff --git a/frontend/assets/img/weapons/lee_enfield_n4.PNG b/frontend/assets/img/weapons/lee_enfield_n4.PNG new file mode 100644 index 0000000..78fa846 Binary files /dev/null and b/frontend/assets/img/weapons/lee_enfield_n4.PNG differ diff --git a/frontend/assets/img/weapons/luger_p08.PNG b/frontend/assets/img/weapons/luger_p08.PNG new file mode 100644 index 0000000..58da18f Binary files /dev/null and b/frontend/assets/img/weapons/luger_p08.PNG differ diff --git a/frontend/assets/img/weapons/m1903_springfield.PNG b/frontend/assets/img/weapons/m1903_springfield.PNG new file mode 100644 index 0000000..0d3bf0b Binary files /dev/null and b/frontend/assets/img/weapons/m1903_springfield.PNG differ diff --git a/frontend/assets/img/weapons/m1_carabine.PNG b/frontend/assets/img/weapons/m1_carabine.PNG new file mode 100644 index 0000000..99b9e53 Binary files /dev/null and b/frontend/assets/img/weapons/m1_carabine.PNG differ diff --git a/frontend/assets/img/weapons/m1_garand.PNG b/frontend/assets/img/weapons/m1_garand.PNG new file mode 100644 index 0000000..66ec70e Binary files /dev/null and b/frontend/assets/img/weapons/m1_garand.PNG differ diff --git a/frontend/assets/img/weapons/m2_flamethrower.PNG b/frontend/assets/img/weapons/m2_flamethrower.PNG new file mode 100644 index 0000000..3965dca Binary files /dev/null and b/frontend/assets/img/weapons/m2_flamethrower.PNG differ diff --git a/frontend/assets/img/weapons/m3_grease_gun.PNG b/frontend/assets/img/weapons/m3_grease_gun.PNG new file mode 100644 index 0000000..02bb622 Binary files /dev/null and b/frontend/assets/img/weapons/m3_grease_gun.PNG differ diff --git a/frontend/assets/img/weapons/m97.PNG b/frontend/assets/img/weapons/m97.PNG new file mode 100644 index 0000000..dbe3fdc Binary files /dev/null and b/frontend/assets/img/weapons/m97.PNG differ diff --git a/frontend/assets/img/weapons/mg34.PNG b/frontend/assets/img/weapons/mg34.PNG new file mode 100644 index 0000000..410e558 Binary files /dev/null and b/frontend/assets/img/weapons/mg34.PNG differ diff --git a/frontend/assets/img/weapons/mg42.PNG b/frontend/assets/img/weapons/mg42.PNG new file mode 100644 index 0000000..00376e9 Binary files /dev/null and b/frontend/assets/img/weapons/mg42.PNG differ diff --git a/frontend/assets/img/weapons/mosing_nagant_1891.PNG b/frontend/assets/img/weapons/mosing_nagant_1891.PNG new file mode 100644 index 0000000..1e21c98 Binary files /dev/null and b/frontend/assets/img/weapons/mosing_nagant_1891.PNG differ diff --git a/frontend/assets/img/weapons/mosing_nagant_9130.PNG b/frontend/assets/img/weapons/mosing_nagant_9130.PNG new file mode 100644 index 0000000..22771ec Binary files /dev/null and b/frontend/assets/img/weapons/mosing_nagant_9130.PNG differ diff --git a/frontend/assets/img/weapons/mosing_nagant_m38.PNG b/frontend/assets/img/weapons/mosing_nagant_m38.PNG new file mode 100644 index 0000000..4af4b9e Binary files /dev/null and b/frontend/assets/img/weapons/mosing_nagant_m38.PNG differ diff --git a/frontend/assets/img/weapons/mp40.PNG b/frontend/assets/img/weapons/mp40.PNG new file mode 100644 index 0000000..359cd8d Binary files /dev/null and b/frontend/assets/img/weapons/mp40.PNG differ diff --git a/frontend/assets/img/weapons/nagant_m1895.PNG b/frontend/assets/img/weapons/nagant_m1895.PNG new file mode 100644 index 0000000..62755d7 Binary files /dev/null and b/frontend/assets/img/weapons/nagant_m1895.PNG differ diff --git a/frontend/assets/img/weapons/panzerchreck.PNG b/frontend/assets/img/weapons/panzerchreck.PNG new file mode 100644 index 0000000..ec563e5 Binary files /dev/null and b/frontend/assets/img/weapons/panzerchreck.PNG differ diff --git a/frontend/assets/img/weapons/piat.PNG b/frontend/assets/img/weapons/piat.PNG new file mode 100644 index 0000000..534573e Binary files /dev/null and b/frontend/assets/img/weapons/piat.PNG differ diff --git a/frontend/assets/img/weapons/ppsh41.PNG b/frontend/assets/img/weapons/ppsh41.PNG new file mode 100644 index 0000000..174de04 Binary files /dev/null and b/frontend/assets/img/weapons/ppsh41.PNG differ diff --git a/frontend/assets/img/weapons/ppsh_41w_drum.PNG b/frontend/assets/img/weapons/ppsh_41w_drum.PNG new file mode 100644 index 0000000..23227fd Binary files /dev/null and b/frontend/assets/img/weapons/ppsh_41w_drum.PNG differ diff --git a/frontend/assets/img/weapons/ptrs41.PNG b/frontend/assets/img/weapons/ptrs41.PNG new file mode 100644 index 0000000..eb21493 Binary files /dev/null and b/frontend/assets/img/weapons/ptrs41.PNG differ diff --git a/frontend/assets/img/weapons/scoped_mosin_nagant_9130.PNG b/frontend/assets/img/weapons/scoped_mosin_nagant_9130.PNG new file mode 100644 index 0000000..6f98634 Binary files /dev/null and b/frontend/assets/img/weapons/scoped_mosin_nagant_9130.PNG differ diff --git a/frontend/assets/img/weapons/scoped_svt40.PNG b/frontend/assets/img/weapons/scoped_svt40.PNG new file mode 100644 index 0000000..0e10b99 Binary files /dev/null and b/frontend/assets/img/weapons/scoped_svt40.PNG differ diff --git a/frontend/assets/img/weapons/sten_mk_v.PNG b/frontend/assets/img/weapons/sten_mk_v.PNG new file mode 100644 index 0000000..b6df7f2 Binary files /dev/null and b/frontend/assets/img/weapons/sten_mk_v.PNG differ diff --git a/frontend/assets/img/weapons/stg44.PNG b/frontend/assets/img/weapons/stg44.PNG new file mode 100644 index 0000000..95e5a81 Binary files /dev/null and b/frontend/assets/img/weapons/stg44.PNG differ diff --git a/frontend/assets/img/weapons/svt40.PNG b/frontend/assets/img/weapons/svt40.PNG new file mode 100644 index 0000000..7d18f2e Binary files /dev/null and b/frontend/assets/img/weapons/svt40.PNG differ diff --git a/frontend/assets/img/weapons/thompson.PNG b/frontend/assets/img/weapons/thompson.PNG new file mode 100644 index 0000000..f96f7dc Binary files /dev/null and b/frontend/assets/img/weapons/thompson.PNG differ diff --git a/frontend/assets/img/weapons/tokarev_tt33.PNG b/frontend/assets/img/weapons/tokarev_tt33.PNG new file mode 100644 index 0000000..b693240 Binary files /dev/null and b/frontend/assets/img/weapons/tokarev_tt33.PNG differ diff --git a/frontend/assets/img/weapons/walther_p38.PNG b/frontend/assets/img/weapons/walther_p38.PNG new file mode 100644 index 0000000..71ab2b2 Binary files /dev/null and b/frontend/assets/img/weapons/walther_p38.PNG differ diff --git a/frontend/assets/img/weapons/webley_revolver.PNG b/frontend/assets/img/weapons/webley_revolver.PNG new file mode 100644 index 0000000..4a5d7d9 Binary files /dev/null and b/frontend/assets/img/weapons/webley_revolver.PNG differ diff --git a/frontend/assets/js/historico-partida.js b/frontend/assets/js/historico-partida.js index 3d05a88..25ad79e 100644 --- a/frontend/assets/js/historico-partida.js +++ b/frontend/assets/js/historico-partida.js @@ -14,6 +14,11 @@ document.addEventListener("DOMContentLoaded", () => { playersSection: document.getElementById("match-detail-players-section"), playersNote: document.getElementById("match-detail-players-note"), playersState: document.getElementById("match-detail-players-state"), + playerControls: document.getElementById("match-detail-player-controls"), + playerSearch: document.getElementById("match-detail-player-search"), + playerTeamFilters: [...document.querySelectorAll('input[name="match-detail-player-team-filter"]')], + playerSort: document.getElementById("match-detail-player-sort"), + playerSortDirection: document.getElementById("match-detail-player-sort-direction"), playersTableShell: document.getElementById("match-detail-players-table-shell"), playersBody: document.getElementById("match-detail-players-body"), timelineSection: document.getElementById("match-detail-timeline-section"), @@ -206,33 +211,458 @@ function renderPlayerSection(item, nodes) { nodes.playersState, "No hay filas de jugador registradas para esta partida.", ); + nodes.playerControls.hidden = true; nodes.playersTableShell.hidden = true; nodes.playersBody.innerHTML = ""; return; } - nodes.playersNote.textContent = `${formatNumber(players.length)} jugadores con estadisticas locales.`; - nodes.playersState.hidden = true; - nodes.playersBody.innerHTML = players.map((player) => renderPlayerRow(player)).join(""); + const state = { + search: "", + team: "all", + sort: "kills", + direction: "desc", + isDefaultSort: true, + }; + const renderRows = () => renderPlayerTable(item, players, state, nodes); + + nodes.playerSearch.value = ""; + nodes.playerTeamFilters.forEach((control) => { + control.checked = control.value === state.team; + }); + nodes.playerSort.value = state.sort; + nodes.playerSortDirection.value = state.direction; + bindPlayerTableControls(nodes, state, renderRows); + renderRows(); + nodes.playerControls.hidden = false; nodes.playersTableShell.hidden = false; } -function renderPlayerRow(player) { +function bindPlayerTableControls(nodes, state, renderRows) { + nodes.playerControls.onsubmit = (event) => { + event.preventDefault(); + }; + nodes.playerSearch.oninput = () => { + closePlayerDetailRows(nodes.playersBody); + state.search = nodes.playerSearch.value; + renderRows(); + }; + nodes.playerTeamFilters.forEach((control) => { + control.onchange = () => { + closePlayerDetailRows(nodes.playersBody); + state.team = control.value; + renderRows(); + }; + }); + nodes.playerSort.onchange = () => { + state.sort = nodes.playerSort.value; + state.isDefaultSort = false; + renderRows(); + }; + nodes.playerSortDirection.onchange = () => { + state.direction = nodes.playerSortDirection.value; + state.isDefaultSort = false; + renderRows(); + }; +} + +function renderPlayerTable(item, players, state, nodes) { + const visiblePlayers = getVisiblePlayers(players, item, state); + nodes.playersNote.textContent = + visiblePlayers.length === players.length + ? `${formatNumber(players.length)} jugadores con estadisticas locales.` + : `${formatNumber(visiblePlayers.length)} de ${formatNumber(players.length)} jugadores visibles.`; + nodes.playersState.hidden = visiblePlayers.length > 0; + if (!visiblePlayers.length) { + nodes.playersState.textContent = "No hay jugadores que coincidan con los controles activos."; + } + nodes.playersBody.innerHTML = visiblePlayers + .map((entry, index) => renderPlayerRows(entry.player, item, index, entry.inactive)) + .join(""); + bindPlayerDetailRows(nodes.playersBody); +} + +function getVisiblePlayers(players, item, state) { + const normalizedSearch = normalizeLookupText(state.search); + return players + .map((player) => ({ + player, + inactive: isInactiveMatchPlayer(player), + team: getTeamSideDisplay(player.team || player.team_side), + })) + .filter((entry) => { + const matchesTeam = state.team === "all" || entry.team.key === state.team; + const matchesName = + !normalizedSearch || + normalizeLookupText(entry.player.player_name).includes(normalizedSearch); + return matchesTeam && matchesName; + }) + .sort((a, b) => comparePlayerEntries(a, b, item, state)); +} + +function comparePlayerEntries(a, b, item, state) { + if (state.isDefaultSort) { + return ( + compareInactivePriority(a, b) || + compareNumericStat(b.player.kills, a.player.kills) || + compareNumericStat(a.player.deaths, b.player.deaths) || + comparePlayerNames(a.player, b.player) + ); + } + + if (!["name", "team"].includes(state.sort)) { + const inactivePriority = compareInactivePriority(a, b); + if (inactivePriority) { + return inactivePriority; + } + } + + const direction = state.direction === "asc" ? 1 : -1; + const compared = comparePlayerSortValue(a, b, item, state.sort); + return compared * direction || comparePlayerNames(a.player, b.player); +} + +function comparePlayerSortValue(a, b, item, sort) { + if (sort === "name") { + return comparePlayerNames(a.player, b.player); + } + if (sort === "team") { + return compareText(a.team.label, b.team.label); + } + if (sort === "deaths" || sort === "teamkills" || sort === "kills") { + return compareNumericStat(a.player[sort], b.player[sort]); + } + if (sort === "kd") { + return compareNumericStat(getKdRatioValue(a.player), getKdRatioValue(b.player)); + } + return compareNumericStat( + getKpmValue(a.player.kills, item.duration_seconds), + getKpmValue(b.player.kills, item.duration_seconds), + ); +} + +function compareInactivePriority(a, b) { + return Number(a.inactive) - Number(b.inactive); +} + +function comparePlayerNames(a, b) { + return compareText(getPlayerName(a), getPlayerName(b)); +} + +function compareText(a, b) { + return String(a || "").localeCompare(String(b || ""), "es", { + sensitivity: "base", + }); +} + +function compareNumericStat(a, b) { + return toSortableNumber(a) - toSortableNumber(b); +} + +function renderPlayerRows(player, item, index, inactive = false) { + const team = getTeamSideDisplay(player.team || player.team_side); + const rowId = `match-player-row-${index}`; + const panelId = `match-player-panel-${index}`; + const playerName = getPlayerName(player); + const kpm = formatKpm(player.kills, item.duration_seconds); return ` - - ${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))} ${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) { + const playerRows = [...playersBody.querySelectorAll(".historical-player-row")]; + const collapseRow = (row) => { + const button = row.querySelector(".historical-player-row__details-button"); + const detailRow = row.nextElementSibling; + if (!button || !detailRow?.classList.contains("historical-player-detail-row")) { + return; + } + row.classList.remove("is-expanded"); + detailRow.classList.remove("is-open"); + button.setAttribute("aria-expanded", "false"); + }; + + playerRows.forEach((row) => { + const button = row.querySelector(".historical-player-row__details-button"); + const detailRow = row.nextElementSibling; + if (!button || !detailRow?.classList.contains("historical-player-detail-row")) { + return; + } + const setExpanded = (expanded) => { + if (expanded) { + playerRows.filter((candidate) => candidate !== row).forEach(collapseRow); + } + row.classList.toggle("is-expanded", expanded); + detailRow.classList.toggle("is-open", expanded); + button.setAttribute("aria-expanded", String(expanded)); + }; + const toggleExpanded = () => setExpanded(!detailRow.classList.contains("is-open")); + + button.addEventListener("click", () => { + toggleExpanded(); + }); + }); +} + +function closePlayerDetailRows(playersBody) { + [...playersBody.querySelectorAll(".historical-player-row")].forEach((row) => { + const button = row.querySelector(".historical-player-row__details-button"); + const detailRow = row.nextElementSibling; + if (!button || !detailRow?.classList.contains("historical-player-detail-row")) { + return; + } + row.classList.remove("is-expanded"); + detailRow.classList.remove("is-open"); + button.setAttribute("aria-expanded", "false"); + }); +} + +function getPlayerName(player) { + return player.player_name || player.name || "Jugador no identificado"; +} + +function isInactiveMatchPlayer(player) { + const team = getTeamSideDisplay(player.team || player.team_side); + return ( + team.key === "unknown" && + toSortableNumber(player.kills) === 0 && + toSortableNumber(player.deaths) === 0 && + toSortableNumber(player.teamkills) === 0 && + getKdRatioValue(player) === 0 && + !hasNamedCounts(player.top_weapons) && + !hasNamedCounts(player.most_killed) && + !hasNamedCounts(player.death_by) + ); +} + +function renderPlayerStatsPanel(player, item, context) { + const matchups = buildPlayerDirectMatchups(player); + const hasExpandedStats = + hasNamedCounts(player.top_weapons) || + hasNamedCounts(player.most_killed) || + hasNamedCounts(player.death_by) || + matchups.length > 0; + + return ` +
+
+
+

${escapeHtml(context.team.label)}

+

${escapeHtml(context.playerName)}

+
+
+ ${renderPlayerStatChip("Kills", formatOptionalNumber(player.kills))} + ${renderPlayerStatChip("Muertes", formatOptionalNumber(player.deaths))} + ${renderPlayerStatChip("TK", formatOptionalNumber(player.teamkills))} + ${renderPlayerStatChip("KD", formatKdRatio(player))} + ${renderPlayerStatChip("KPM", context.kpm)} +
+
+ ${renderExternalProfilesSection(player)} + ${ + 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 renderExternalProfilesSection(player) { + const links = [ + ["steam", "Steam"], + ["hellor", "Hellor"], + ["hll_records", "HLL Records"], + ["helo", "Helo"], + ] + .map(([key, label]) => [label, player.external_profile_links?.[key]]) + .filter(([, href]) => typeof href === "string" && href.trim()); + + return ` +
+
Perfiles externos
+ ${ + links.length + ? ` + + ` + : 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 ` +
+ ${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) { @@ -243,6 +673,7 @@ function renderActions(item, actionsNode) { actionsNode.innerHTML = ` 0 ? formatDecimal(kills / deaths, 2) : formatDecimal(kills, 2); + return deaths > 0 ? kills / deaths : kills; +} + +function formatKpm(kills, durationSeconds) { + return formatDecimal(getKpmValue(kills, durationSeconds), 2); +} + +function getKpmValue(kills, durationSeconds) { + const parsedKills = Number(kills); + const parsedDurationSeconds = Number(durationSeconds); + if ( + !Number.isFinite(parsedKills) || + !Number.isFinite(parsedDurationSeconds) || + parsedDurationSeconds <= 0 + ) { + return 0; + } + return parsedKills / (parsedDurationSeconds / 60); } function formatNamedCounts(items) { @@ -414,6 +878,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)) { @@ -422,6 +890,19 @@ function formatNumber(value) { return new Intl.NumberFormat("es-ES").format(parsedValue); } +function toSortableNumber(value) { + const parsedValue = Number(value); + return Number.isFinite(parsedValue) ? parsedValue : 0; +} + +function formatSignedNumber(value) { + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue) || parsedValue === 0) { + return "0"; + } + return `${parsedValue > 0 ? "+" : ""}${formatNumber(parsedValue)}`; +} + function formatDecimal(value, fractionDigits = 1) { const parsedValue = Number(value); if (!Number.isFinite(parsedValue)) { diff --git a/frontend/assets/js/historico-recent-live.js b/frontend/assets/js/historico-recent-live.js index fe3c1cb..fc539d3 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,67 @@ 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 actionLinks = [ + `${escapeDynamicHtml(formatDynamicResultLabel(item?.result))}`, + detailUrl + ? `Ver detalles` + : "", + ].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 +130,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 +145,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 +168,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..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, @@ -802,9 +944,9 @@ function hydrateMvpComparison( function renderRecentMatchCard(item) { const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible"; - const matchUrl = normalizeExternalMatchUrl(item.match_url); const detailUrl = buildInternalMatchDetailUrl(item); const actionLinks = [ + `${escapeHtml(formatMatchResult(item.result))}`, detailUrl ? ` ` : "", - matchUrl - ? ` - - Ver partida - - ` - : "", ].join(""); return ` -
-
-
-

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 +981,10 @@ function renderRecentMatchCard(item) {

Marcador

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

Estado

- ${escapeHtml(formatRecentMatchStatus(item))} +
+
+ ${actionLinks} +
diff --git a/frontend/assets/js/main.js b/frontend/assets/js/main.js index 4f2b0a2..3400fdb 100644 --- a/frontend/assets/js/main.js +++ b/frontend/assets/js/main.js @@ -1,9 +1,16 @@ // Progressive enhancement for local frontend-backend checks. const DEFAULT_SERVER_POLL_INTERVAL_MS = 300 * 1000; -const SERVER_HISTORY_URLS_FALLBACK = Object.freeze({ - "comunidad-hispana-01": "https://scoreboard.comunidadhll.es/games", - "comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443/games", - "comunidad-hispana-03": "https://scoreboard.comunidadhll.es:3443/games", +const TRUSTED_SERVER_ACTIONS = Object.freeze({ + "comunidad-hispana-01": Object.freeze({ + publicScoreboardUrl: "https://scoreboard.comunidadhll.es", + historicalUrl: "./historico.html?server=comunidad-hispana-01", + currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-01", + }), + "comunidad-hispana-02": Object.freeze({ + publicScoreboardUrl: "https://scoreboard.comunidadhll.es:5443", + historicalUrl: "./historico.html?server=comunidad-hispana-02", + currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-02", + }), }); const COMMUNITY_CLANS = Object.freeze([ { @@ -266,16 +273,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 `
@@ -287,10 +297,12 @@ function renderServerStatsCard(server) {
${escapeHtml(statusLabel)}

${escapeHtml(`${players} / ${maxPlayers}`)}

- ${actionMarkup}
- ${quickFacts} +
+ ${quickFacts} + ${actionMarkup} +
`; } @@ -299,36 +311,45 @@ function renderServerSections(latestItems) { return latestItems.map((server) => renderServerStatsCard(server)).join(""); } -function renderServerAction(server) { - const historyState = getServerHistoryState(server); - if (!historyState.available) { - return ` -
- - Historico no disponible - -
- `; +function normalizeServerRegion(value) { + if (typeof value !== "string") { + return ""; } + const trimmedValue = value.trim(); + if (!trimmedValue) { + return ""; + } + const normalizedValue = trimmedValue + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase(); + const placeholderValues = new Set([ + "region pendiente", + "region pending", + "pending", + "unknown", + "desconocida", + "no disponible", + "por confirmar", + "n/a", + ]); + return placeholderValues.has(normalizedValue) ? "" : trimmedValue; +} - const historyUrl = historyState.url; - if (!historyUrl) { +function renderServerAction(server) { + const actions = getTrustedServerActions(server); + if (!actions) { return ""; } return ` `; } @@ -430,28 +451,66 @@ 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 trustedActionKey = resolveTrustedServerActionKey(server); + return TRUSTED_SERVER_ACTIONS[trustedActionKey] || null; +} - const externalServerId = - typeof server?.external_server_id === "string" - ? server.external_server_id.trim() - : ""; - if (!externalServerId) { +function resolveTrustedServerActionKey(server) { + if (!server) { return ""; } - return SERVER_HISTORY_URLS_FALLBACK[externalServerId] || ""; + 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 getServerHistoryState(server) { - const historyUrl = getServerHistoryUrl(server); - return { - available: Boolean(historyUrl), - url: historyUrl, - }; +function getTrimmedServerValue(value) { + return typeof value === "string" ? value.trim() : ""; } 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..d0e4505 --- /dev/null +++ b/frontend/assets/js/partida-actual.js @@ -0,0 +1,743 @@ +const CURRENT_MATCH_POLL_INTERVAL_MS = 30 * 1000; +const CURRENT_MATCH_KILL_FEED_POLL_INTERVAL_MS = 1500; +const CURRENT_MATCH_PLAYER_STATS_POLL_INTERVAL_MS = 3000; +const CURRENT_MATCH_SERVERS = Object.freeze({ + "comunidad-hispana-01": "Comunidad Hispana #01", + "comunidad-hispana-02": "Comunidad Hispana #02", +}); +const CURRENT_MATCH_SCOREBOARDS = Object.freeze({ + "comunidad-hispana-01": "https://scoreboard.comunidadhll.es", + "comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443", +}); +const CURRENT_MATCH_KILL_FEED_LIMIT = 18; +const CURRENT_MATCH_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: "" }, +}); + +document.addEventListener("DOMContentLoaded", () => { + const params = new URLSearchParams(window.location.search); + const serverSlug = params.get("server") || ""; + const nodes = { + title: document.getElementById("current-match-title"), + summary: document.getElementById("current-match-summary"), + history: document.getElementById("current-match-history"), + scoreboard: document.getElementById("current-match-scoreboard"), + note: document.getElementById("current-match-note"), + state: document.getElementById("current-match-state"), + grid: document.getElementById("current-match-grid"), + feedTitle: document.getElementById("current-match-feed-title"), + playersTitle: document.getElementById("current-match-players-title"), + mapHero: document.getElementById("current-match-map-hero"), + mapImage: document.getElementById("current-match-map-image"), + mapPlaceholder: document.getElementById("current-match-map-placeholder"), + }; + const backendBaseUrl = + document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; + + if (!CURRENT_MATCH_SERVERS[serverSlug]) { + renderUnsupportedServer(nodes); + return; + } + + nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`; + const killFeedState = initializeKillFeed(nodes); + const playerStatsState = initializePlayerStats(nodes); + let currentMatchRefreshInFlight = false; + const refreshCurrentMatch = async () => { + if (currentMatchRefreshInFlight) { + return; + } + currentMatchRefreshInFlight = true; + try { + await loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }); + } finally { + currentMatchRefreshInFlight = false; + } + }; + + let killFeedRefreshInFlight = false; + const refreshKillFeed = async () => { + if (killFeedRefreshInFlight) { + return; + } + killFeedRefreshInFlight = true; + try { + await loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }); + } finally { + killFeedRefreshInFlight = false; + } + }; + + let playerStatsRefreshInFlight = false; + const refreshPlayerStats = async () => { + if (playerStatsRefreshInFlight) { + return; + } + playerStatsRefreshInFlight = true; + try { + await loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }); + } finally { + playerStatsRefreshInFlight = false; + } + }; + + void refreshCurrentMatch(); + void refreshKillFeed(); + void refreshPlayerStats(); + window.setInterval(() => { + void refreshCurrentMatch(); + }, CURRENT_MATCH_POLL_INTERVAL_MS); + window.setInterval(() => { + void refreshKillFeed(); + }, CURRENT_MATCH_KILL_FEED_POLL_INTERVAL_MS); + window.setInterval(() => { + void refreshPlayerStats(); + }, CURRENT_MATCH_PLAYER_STATS_POLL_INTERVAL_MS); +}); + +async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) { + try { + const payload = await fetchJson( + `${backendBaseUrl}/api/current-match?server=${encodeURIComponent(serverSlug)}`, + ); + renderCurrentMatch(payload?.data || {}, nodes); + } catch (error) { + nodes.note.textContent = "Se conserva el ultimo estado visible si estaba disponible."; + setState(nodes.state, "No se pudo actualizar la partida actual.", true); + } +} + +async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) { + try { + const cursor = killFeedState.latestEventId + ? `&since_event_id=${encodeURIComponent(killFeedState.latestEventId)}` + : ""; + const payload = await fetchJson( + `${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=${CURRENT_MATCH_KILL_FEED_LIMIT}${cursor}`, + ); + renderKillFeed(payload?.data || {}, nodes, killFeedState); + } catch (error) { + setState(nodes.feedState, "No se pudo actualizar el feed de combate.", true); + } +} + +async function loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }) { + try { + const payload = await fetchJson( + `${backendBaseUrl}/api/current-match/players?server=${encodeURIComponent(serverSlug)}`, + ); + renderPlayerStats(payload?.data || {}, nodes, playerStatsState); + } catch (error) { + setState( + nodes.playerStatsState, + "Todavía no hay estadísticas fiables de jugadores para esta partida.", + true, + ); + } +} + +function renderCurrentMatch(data, nodes) { + const rawServerName = data.server_name || data.server_slug || "Servidor no disponible"; + const serverName = formatServerDisplayName(data, rawServerName); + const mapName = data.map_pretty_name || data.map || "Mapa no disponible"; + const scoreboardUrl = resolveTrustedScoreboardUrl(data); + nodes.title.textContent = mapName; + nodes.summary.textContent = serverName; + nodes.note.textContent = data.found + ? "Lectura en vivo recibida. El feed de bajas se actualiza en tiempo casi real." + : "Todavia no hay snapshot live disponible para este servidor."; + nodes.scoreboard.href = scoreboardUrl || "./index.html"; + nodes.scoreboard.hidden = !scoreboardUrl; + renderMapHero(data, mapName, nodes); + nodes.grid.innerHTML = renderLiveScoreboard(data, { mapName, serverName }); + nodes.state.hidden = true; + nodes.grid.hidden = false; +} + +function renderUnsupportedServer(nodes) { + nodes.title.textContent = "Servidor no soportado"; + nodes.summary.textContent = + "Abre esta vista desde una tarjeta activa de Comunidad Hispana."; + nodes.note.textContent = ""; + nodes.scoreboard.hidden = true; + nodes.grid.hidden = true; + renderMapHero({}, "Mapa no disponible", nodes); + setState(nodes.state, "No se puede consultar la partida solicitada.", true); +} + +function initializeKillFeed(nodes) { + const feedShell = nodes.feedTitle?.closest(".panel__shell"); + if (feedShell) { + feedShell.insertAdjacentHTML( + "beforeend", + ` +

+ Cargando feed de combate... +

+
+
+
+ `, + ); + } + nodes.feedState = document.getElementById("current-match-feed-state"); + nodes.feedList = document.getElementById("current-match-feed-list"); + return { + byId: new Map(), + latestEventId: "", + visibleSignature: "", + }; +} + +function initializePlayerStats(nodes) { + const shell = nodes.playersTitle?.closest(".panel__shell"); + if (shell) { + shell.insertAdjacentHTML( + "beforeend", + ` +

+ Cargando estadisticas en vivo... +

+ + `, + ); + } + nodes.playerStatsState = document.getElementById("current-match-player-stats-state"); + nodes.playerCount = document.getElementById("current-match-player-count"); + nodes.playerStatsShell = document.getElementById("current-match-player-stats-shell"); + return { + visibleSignature: "", + }; +} + +function renderKillFeed(data, nodes, state) { + const incoming = Array.isArray(data.items) ? data.items : []; + if (data.scope === "no-current-match-events") { + state.byId.clear(); + state.latestEventId = ""; + } + incoming.forEach((event) => { + if (event?.event_id) { + state.byId.set(event.event_id, event); + } + }); + const events = [...state.byId.values()] + .sort(compareKillFeedEvents) + .slice(-CURRENT_MATCH_KILL_FEED_LIMIT); + state.byId = new Map(events.map((event) => [event.event_id, event])); + state.latestEventId = events[events.length - 1]?.event_id || state.latestEventId; + if (events.length === 0) { + nodes.feedList.innerHTML = ""; + state.visibleSignature = ""; + setState(nodes.feedState, "Todavía no se han detectado bajas en esta partida."); + return; + } + const visualEvents = events; + const visibleSignature = visualEvents.map((event) => event.event_id).join("|"); + if (visibleSignature !== state.visibleSignature) { + nodes.feedList.innerHTML = renderKillFeedColumns(visualEvents); + state.visibleSignature = visibleSignature; + } + nodes.feedState.textContent = formatKillFeedCoverage(data.scope); + nodes.feedState.classList.remove("historical-state--error"); +} + +function compareKillFeedEvents(left, right) { + const leftTime = Number(left.server_time); + const rightTime = Number(right.server_time); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + return ( + String(left.event_timestamp || "").localeCompare(String(right.event_timestamp || "")) || + String(left.event_id || "").localeCompare(String(right.event_id || "")) + ); +} + +function renderKillFeedColumns(events) { + const splitIndex = Math.ceil(events.length / 2); + return [events.slice(0, splitIndex), events.slice(splitIndex)] + .map( + (columnEvents) => ` +
+ ${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' + : ""; + return ` +
+ + + ${escapeHtml(event.killer_name || "Jugador no disponible")} + + + ${renderKillFeedTeamBadge(killerTeam)} + ${teamkillBadge} + + + + ${renderKillFeedWeaponIcon(weapon)} + ${escapeHtml(weapon.label)} + + + + ${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] || { + label: String(value || CURRENT_MATCH_WEAPONS.unknown.label), + icon: CURRENT_MATCH_WEAPONS.unknown.icon, + }; +} + +function renderKillFeedWeaponIcon(weapon) { + if (!weapon.icon) { + return ''; + } + return ` + + + `; +} + +function renderPlayerStats(data, nodes, state) { + const items = Array.isArray(data.items) ? sortPlayerStats(data.items) : []; + renderDetectedPlayerCount(items.length, nodes); + if (items.length === 0) { + state.visibleSignature = ""; + nodes.playerStatsShell.innerHTML = ""; + nodes.playerStatsShell.hidden = true; + setState( + nodes.playerStatsState, + "Todavía no hay estadísticas fiables de jugadores para esta partida.", + ); + return; + } + const signature = items + .map((item) => + [ + item.player_name, + item.team, + item.kills, + item.deaths, + item.teamkills, + item.deaths_by_teamkill, + item.favorite_weapon, + item.last_seen_at, + ].join(":"), + ) + .join("|"); + if (signature !== state.visibleSignature) { + nodes.playerStatsShell.innerHTML = renderPlayerStatsTable(items); + state.visibleSignature = signature; + } + nodes.playerStatsShell.hidden = false; + setState(nodes.playerStatsState, "Estadisticas 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) => + toStatNumber(right.kills) - toStatNumber(left.kills) || + toStatNumber(left.deaths) - toStatNumber(right.deaths) || + String(left.player_name || "").localeCompare(String(right.player_name || ""), "es", { + sensitivity: "base", + }), + ); +} + +function renderPlayerStatsTable(items) { + return ` + + + + + + + + + + + + + + ${items.map(renderPlayerStatsRow).join("")} + +
JugadorEquipoBajasMuertesTKMuertes TKArma frecuente
+ `; +} + +function renderPlayerStatsRow(item) { + const team = getPlayerTeamDisplay(item.team); + return ` + + ${escapeHtml(item.player_name || "Jugador no disponible")} + + + ${escapeHtml(team.label)} + + + ${escapeHtml(formatStatNumber(item.kills))} + ${escapeHtml(formatStatNumber(item.deaths))} + ${escapeHtml(formatStatNumber(item.teamkills))} + ${escapeHtml(formatStatNumber(item.deaths_by_teamkill))} + ${escapeHtml(item.favorite_weapon || "No disponible")} + + `; +} + +function getPlayerTeamDisplay(value) { + const normalized = String(value || "").trim().toLowerCase(); + if (normalized === "allies" || normalized === "allied" || normalized === "aliados") { + return { key: "allies", label: "Aliados" }; + } + if (normalized === "axis" || normalized === "eje") { + return { key: "axis", label: "Eje" }; + } + return { key: "unknown", label: "No disponible" }; +} + +function toStatNumber(value) { + return Number.isFinite(Number(value)) ? Number(value) : 0; +} + +function formatStatNumber(value) { + return Number.isFinite(Number(value)) ? String(Number(value)) : "0"; +} + +function renderCompactMeta(label, value) { + return ` +
+ ${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 (!isNumericValue(players) || !isNumericValue(maxPlayers)) { + return "No disponible"; + } + return `${Number(players)} / ${Number(maxPlayers)}`; +} + +function formatTimestamp(value) { + if (!value) { + return "No disponible"; + } + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return "No disponible"; + } + return new Intl.DateTimeFormat("es-ES", { + dateStyle: "short", + timeStyle: "short", + }).format(timestamp); +} + +function renderLiveScoreboard(data, { mapName, serverName }) { + const scoreKnown = hasKnownScore(data); + const scoreMarkup = scoreKnown + ? `${Number(data.allied_score)} : ${Number(data.axis_score)}` + : "Marcador no disponible"; + const scoreClass = scoreKnown ? "" : " current-match-scoreboard-message"; + const metadata = [ + ["Servidor", serverName], + ["Mapa", mapName], + ["Modo", formatGameMode(data.game_mode)], + ]; + if (data.started_at) { + metadata.push(["Inicio", formatTimestamp(data.started_at)]); + } + const remainingTime = Number(data.remaining_match_time_seconds); + if (Number.isFinite(remainingTime) && remainingTime > 0) { + metadata.push(["Tiempo restante", formatDuration(remainingTime)]); + } + const matchTime = Number(data.match_time_seconds); + if (Number.isFinite(matchTime) && matchTime > 0) { + metadata.push(["Tiempo de partida", formatDuration(matchTime)]); + } + metadata.push(["Jugadores", formatPlayerCount(data)]); + metadata.push(["Actualizado", formatTimestamp(data.captured_at || data.updated_at)]); + + return ` +
+
+ ${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 formatServerDisplayName(data, fallbackName) { + const trustedName = CURRENT_MATCH_SERVERS[data.server_slug]; + if (trustedName) { + return trustedName; + } + + const normalized = String(fallbackName || "").trim(); + const serverNumber = normalized.match(/^#0?([1-9])\b/); + if (serverNumber) { + return `Comunidad Hispana #${serverNumber[1].padStart(2, "0")}`; + } + + return normalized || "Servidor no disponible"; +} + +function hasKnownScore(data) { + return isNumericValue(data.allied_score) && isNumericValue(data.axis_score); +} + +function formatPlayerCount(data) { + if (!isReliablePlayerCount(data.player_count_quality)) { + return "No verificado"; + } + return formatPlayers(data.players, data.max_players); +} + +function isReliablePlayerCount(quality) { + return quality === "reliable" || quality === "a2s-query"; +} + +function isNumericValue(value) { + return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)); +} + +function formatGameMode(value) { + if (!value) { + return "No disponible"; + } + const normalized = String(value).replaceAll("_", " ").replaceAll("-", " "); + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function formatDuration(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) { + return "No disponible"; + } + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return hours > 0 ? `${hours} h ${remainingMinutes} min` : `${minutes} min`; +} + +function formatKillFeedCoverage(scope) { + if (scope === "open-admin-log-match-window") { + return "Bajas detectadas en la partida actual."; + } + if (scope === "recent-admin-log-window") { + return "Cobertura parcial desde AdminLog reciente."; + } + if (scope === "no-current-match-events") { + return "Todavía no se han detectado bajas en esta partida."; + } + return "Todavía no se han detectado bajas en esta partida."; +} + +function normalizeLookupText(value) { + return String(value || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function setState(node, message, isError = false) { + node.textContent = message; + node.hidden = false; + node.classList.toggle("historical-state--error", isError); +} + +async function fetchJson(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed with ${response.status}`); + } + return response.json(); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/frontend/historico-partida.html b/frontend/historico-partida.html index 82bbb65..ab4833f 100644 --- a/frontend/historico-partida.html +++ b/frontend/historico-partida.html @@ -76,6 +76,7 @@ Cargando detalle...

+ diff --git a/frontend/partida-actual.html b/frontend/partida-actual.html new file mode 100644 index 0000000..ecf00ce --- /dev/null +++ b/frontend/partida-actual.html @@ -0,0 +1,138 @@ + + + + + + + Partida actual - HLL Vietnam + + + + + +
+
+
+
+
+ + VOLVER INICIO + +

Partida en vivo

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

+ Partida actual +

+

+ Cargando estado del servidor. +

+
+ +
+
+ +
+ Mapa en vivo + Esperando imagen disponible. +
+
+
+
+
+ +
+
+
+
+
+

Estado en vivo

+

Marcador en curso

+

+ Leyendo el ultimo snapshot disponible. +

+
+
+

+ Cargando partida actual... +

+ +
+
+ +
+
+
+
+

Combate

+

Feed de combate

+

+ Leyendo eventos recientes con cobertura segura para esta partida. +

+
+
+
+
+ +
+
+
+
+

Jugadores

+

Estadisticas en vivo

+
+

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

+

+ Jugadores detectados: 0 +

+
+
+
+
+
+
+
+ + + diff --git a/scripts/run-historical-ui-regression-tests.ps1 b/scripts/run-historical-ui-regression-tests.ps1 index da27132..291409f 100644 --- a/scripts/run-historical-ui-regression-tests.ps1 +++ b/scripts/run-historical-ui-regression-tests.ps1 @@ -53,8 +53,8 @@ Assert-NotContains $historicoHtml "Elo/MMR" ` Assert-NotContains $visibleHistoricalText "snapshot" ` "Public snapshot wording was reintroduced in visible historical text." -Assert-Contains $historicoJs "Ver partida" ` - "Recent match cards no longer include the external match action label." +Assert-NotContains $historicoJs "Ver partida" ` + "Recent match cards must not expose the external scoreboard action label." Assert-Contains $historicoJs "Ver detalles" ` "Recent match cards no longer include the internal detail fallback label." Assert-NotContains $historicoJs "item.match_url || item.source_url" `